75 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
237 changed files with 23965 additions and 3015 deletions
+1 -4
View File
@@ -10,10 +10,7 @@
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or with the host.
"forwardPorts": [
3000,
5432
],
"forwardPorts": [3000, 5432],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bun i",
// Use 'postStartCommand' to run commands after the container is started.
+1 -1
View File
@@ -14,10 +14,10 @@ DEMO_MODE=false
DOMAIN=localhost
AUTH_TRUST_HOST="http://localhost"
AUTH_SECRET=your_secret_key_here
STOCK_MANAGER_DEFAULT_LOCALE=en
# ADMIN BOOTSTRAP
ADMIN_BOOTSTRAP_ENABLED=true
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@localhost
ADMIN_NAME=Administrator
ADMIN_PASSWORD=change-me
+5 -1
View File
@@ -12,6 +12,8 @@
# testing
/coverage
/test-results
/playwright-report
# next.js
/.next/
@@ -48,4 +50,6 @@ next-env.d.ts
# Local Pi runtime state
.atl/
.pi
.pi/
openspec/
sdd/
+4
View File
@@ -1,4 +1,5 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
@@ -12,4 +13,7 @@
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+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._
+354 -186
View File
@@ -1,238 +1,406 @@
# Stock Manager Home
Sistema de gestión de inventario y asignación de activos desarrollado con Next.js 15, Prisma, PostgreSQL y NextAuth.
Sistema de gestión de inventario, activos serializados, asignaciones y movimientos construido con Next.js, Prisma, PostgreSQL, NextAuth y Bun.
## 📋 Descripción
Stock Manager es una aplicación web completa para la gestión de inventarios, activos y asignaciones de equipamiento. Permite controlar tanto ítems genéricos (gestionados por cantidad) como activos serializados (con número de serie único), registrar movimientos de stock, y gestionar asignaciones a destinatarios por departamentos.
## ✨ Características principales
### Gestión de Inventario
- **Ítems genéricos**: Productos sin número de serie, gestionados por cantidad en stock
- **Activos serializados**: Equipos individuales con número de serie único
- **Categorías**: Organización jerárquica de productos
- **Control de stock**: Niveles mínimos/máximos, alertas de stock bajo (WIP)
- **Estados múltiples**: Disponible, Asignado, Reservado, En reparación, Averiado, Robado, Dado de baja
### Gestión de Destinatarios
- Registro de empleados/destinatarios por departamento
- Información de contacto (email, teléfono)
- Historial de asignaciones por destinatario
### Sistema de Asignaciones
- Asignación de ítems genéricos (por cantidad)
- Asignación de activos individuales (uno a uno)
- Seguimiento de fechas de asignación y devolución
- Notas y detalles de cada asignación
- Registro del usuario que realiza cada asignación
### Movimientos e Historial
- Registro completo de todos los movimientos de stock
- Tipos de movimiento: IN, OUT, ASSIGNMENT, RETURN, ADJUSTMENT, DELETED
- Trazabilidad completa con stock previo y nuevo
- Auditoría de todos los cambios con usuario y fecha
### Importación de Datos
- Importación masiva vía CSV
- Plantilla descargable para importaciones
- Validación de datos en el proceso de importación
### Sistema de Autenticación y Roles
- Autenticación segura con NextAuth v5
- 4 roles de usuario: ADMIN, MANAGER, STAFF, VIEWER
- Permisos diferenciados según rol
- Contraseñas hasheadas con bcrypt
## 🚀 Tecnologías
- **Framework**: Next.js 15 (App Router)
- **Base de datos**: PostgreSQL 18
- **ORM**: Prisma 6
- **Autenticación**: NextAuth v5
- **UI**: React 19, Tailwind CSS, Shadcn
- **Validación**: Zod
- **Formularios**: React Hook Form
- **Runtime**: Bun (recomendado)
- **Containerización**: Docker + Docker Compose
## 🔨 Desarrollo en entorno DevContainer
Este proyecto incluye configuración para desarrollo en contenedor usando [DevContainer](https://containers.dev/).
1. Abre el proyecto en VS Code y selecciona "Reopen in Container".
2. El entorno instalará dependencias automáticamente (bun i) y lanzará el servidor de desarrollo (bun run dev).
3. El puerto 3000 estará disponible para acceder a la app desde tu navegador.
## 🔨 Desarrollo local
### Prerrequisitos
- Node.js 18+ o Bun
- PostgreSQL 13+ (o usar Docker Compose)
- Git
1. Clonar el repositorio:
```bash
git clone <repo-url>
cd stock-manager
```
2. Instalar dependencias:
## Quick start
```bash
bun install
# o
npm install
cp .env.example .env
bun run db:generate
bun run db:migrate
bun run db:seed
bun run dev
```
3. Configurar variables de entorno:
Abrí la aplicación en [http://localhost:3000](http://localhost:3000).
> `db:seed` crea un administrador inicial cuando no existe ningún admin activo. Configurá las variables `ADMIN_*` en `.env` antes de usarlo en entornos compartidos o productivos.
## Qué hace la aplicación
Stock Manager permite gestionar:
- **Ítems genéricos**: productos gestionados por cantidad de stock.
- **Activos serializados**: equipos individuales con número de serie único.
- **Categorías**: clasificación de ítems y activos.
- **Personas**: personas o departamentos que reciben asignaciones. Cada persona puede tener un usuario del sistema vinculado o existir sin credenciales.
- **Asignaciones**: entrega y devolución de ítems o activos.
- **Movimientos**: historial auditable de entradas, salidas, asignaciones, devoluciones y ajustes.
- **Usuarios del sistema**: gestión unificada con personas, roles, estado activo y reseteo de contraseña.
- **Importación CSV**: flujo legacy de importación masiva, mantenido estructuralmente pero pendiente de rediseño.
## Stack técnico
| Área | Tecnología |
|------|------------|
| Framework | Next.js 16 App Router |
| Runtime/package manager | Bun |
| UI | React 19, Tailwind CSS, Radix UI/Shadcn-style components |
| Formularios | React Hook Form |
| Validación | Zod |
| Autenticación | NextAuth v5 |
| ORM | Prisma 7 |
| Base de datos | PostgreSQL |
| Formato/lint | Biome |
| Deploy | Docker / Docker Compose |
## Internacionalización (i18n)
La aplicación soporta inglés (`en`) y español (`es`) en todas las superficies de usuario. La selección de idioma se persiste mediante una cookie `stock-manager-locale` validada en servidor y se aplica con un cambio de idioma por página sin rutas prefijadas.
Superficies localizadas:
- **Login** y navbar compartida con selector de idioma compacto.
- **Shell común**: sidebar, navegación, search, paginación, botón submit, página de acceso denegado.
- **Inventario**: categorías, ítems, activos, personas, asignaciones, movimientos.
- **Personas**: gestión unificada de personas y usuarios del sistema.
La arquitectura i18n sigue un patrón consistente:
- **Diccionarios tipados** en `src/i18n/dictionaries/en.ts` y `es.ts` con paridad de claves obligatoria.
- **Resolución server-side**: las páginas obtienen `getI18n()` y pasan props acotadas a componentes cliente.
- **Schemas localizados**: builders (`buildCreateXSchema(copy)`) que aceptan copia de diccionario e inyectan mensajes de validación localizados.
- **Actions localizadas**: resuelven locale en servidor, construyen schemas con copia localizada y mapean errores de use-case mediante message mappers.
- **Datos de usuario vs UI**: nombres, emails, seriales y valores de enumeraciones canónicas nunca se traducen; solo se localizan las etiquetas de presentación.
La importación CSV queda fuera del alcance actual de i18n por su rediseño previsto.
## Configuración de entorno
Copiá el ejemplo y completá los valores reales:
```bash
cp .env.example .env
```
Editar `.env` con tus configuraciones:
Variables principales:
```env
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/stockmanager"
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=stockmanager
POSTGRES_HOST=db
POSTGRES_PORT=5432
| Grupo | Variables |
|-------|-----------|
| Base de datos | `DATABASE_URL`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `POSTGRES_HOST`, `POSTGRES_PORT` |
| Auth | `AUTH_SECRET`, `AUTH_TRUST_HOST`, `DOMAIN`, `NODE_ENV`, `DEMO_MODE` |
| Bootstrap admin | `ADMIN_BOOTSTRAP_ENABLED`, `ADMIN_EMAIL`, `ADMIN_NAME`, `ADMIN_PASSWORD` |
# NextAuth
NODE_ENV=development
DEMO_MODE=false
AUTH_SECRET="your-secret-key-here"
AUTH_TRUST_HOST=true
DOMAIN=localhost:3000
```
### Bootstrap admin
4. Ejecutar migraciones de base de datos:
El seed ejecuta `prisma/seed.ts`, que llama a `prisma/bootstrap-admin.ts`.
```bash
bun run db:migrate
# o generar el cliente Prisma
bun run db:generate
```
Comportamiento:
5. (Opcional) Ejecutar seed para datos iniciales:
- Si ya existe un usuario `ADMIN` activo, no hace nada.
- Si `ADMIN_BOOTSTRAP_ENABLED=false`, no crea administrador.
- En producción, `ADMIN_PASSWORD` es obligatorio.
- En desarrollo, si no se define contraseña, usa un valor por defecto sólo para facilitar el arranque local.
Ejecutar manualmente:
```bash
bun run db:seed
```
6. Iniciar el servidor de desarrollo:
## Desarrollo local
### Prerrequisitos
- Bun 1.3+
- PostgreSQL accesible mediante `DATABASE_URL`
- Docker disponible para tests con Testcontainers
- Git
### Pasos
```bash
# 1. Instalar dependencias
bun install
# 2. Configurar entorno
cp .env.example .env
# 3. Generar cliente Prisma
bun run db:generate
# 4. Aplicar migraciones en desarrollo
bun run db:migrate
# 5. Crear admin inicial, si corresponde
bun run db:seed
# 6. Levantar Next
bun run dev
```
Abrir [http://localhost:3000](http://localhost:3000) en el navegador.
## Desarrollo con DevContainer
## 🐳 Despliegue con Docker
El proyecto incluye configuración para desarrollo en contenedor.
### Producción
1. Abrí el repo en VS Code.
2. Elegí **Reopen in Container**.
3. El entorno instala dependencias y puede levantar el servidor de desarrollo.
4. Accedé a [http://localhost:3000](http://localhost:3000).
## Docker / despliegue
Con Docker Compose:
```bash
docker-compose -f compose.yaml up -d
docker compose -f compose.yaml up -d
```
Con Traefik (reverse proxy):
El `Dockerfile` ejecuta al iniciar:
```bash
docker-compose -f compose.yaml -f compose.traefik.yaml up -d
bun run db:deploy && bun run db:seed && bun run start
```
## 📜 Scripts disponibles
Esto aplica migraciones pendientes, ejecuta el bootstrap admin si corresponde y luego inicia Next.
## Scripts disponibles
| Script | Descripción |
|--------|-------------|
| `bun run dev` | Inicia Next en desarrollo con Turbopack |
| `bun run build` | Construye la aplicación para producción |
| `bun run start` | Inicia la build de producción |
| `bun run lint` | Ejecuta Biome lint con escritura de fixes |
| `bun run format` | Formatea con Biome |
| `bun run check` | Ejecuta Biome check con escritura de fixes |
| `bun run test` | Ejecuta toda la suite Vitest: unit + integración |
| `bun run test:unit` | Ejecuta unit tests rápidos |
| `bun run test:integration` | Genera Prisma y ejecuta integration tests con PostgreSQL Testcontainers |
| `bun run test:e2e` | Genera Prisma y ejecuta Playwright E2E smoke con DB aislada |
| `bun run test:coverage` | Ejecuta Vitest con coverage V8 |
| `bun run db:push` | Sincroniza el schema sin crear migraciones |
| `bun run db:migrate` | Crea/aplica migraciones en desarrollo |
| `bun run db:migrate:reset` | Resetea la base y reaplica migraciones |
| `bun run db:deploy` | Aplica migraciones en entornos de deploy |
| `bun run db:generate` | Genera el cliente Prisma en `src/generated/prisma` |
| `bun run db:seed` | Ejecuta el seed/bootstrap admin |
| `bun run db:studio` | Abre Prisma Studio |
## Prisma
El proyecto usa Prisma 7 con configuración en:
```txt
prisma.config.ts
prisma/schema.prisma
prisma/migrations/
```
El cliente Prisma se genera en:
```txt
src/generated/prisma
```
Ese directorio está ignorado por Git. Después de clonar, cambiar schema o instalar dependencias, ejecutá:
```bash
# Desarrollo
bun run dev # Inicia servidor de desarrollo con Turbopack
bun run build # Construye para producción
bun run start # Inicia servidor de producción
# Linting y formato
bun run lint # Ejecuta ESLint
bun run lint:fix # Corrige errores de ESLint automáticamente
bun run format # Formatea código con Prettier
# Base de datos
bun run db:push # Sincroniza schema sin migraciones
bun run db:migrate # Crea y ejecuta migraciones
bun run db:migrate:reset # Resetea BD y ejecuta migraciones
bun run db:deploy # Ejecuta migraciones en producción
bun run db:generate # Genera cliente Prisma
bun run db:studio # Abre Prisma Studio (GUI para BD)
bun run db:generate
```
## 📁 Estructura del proyecto
Validar schema:
```bash
bunx prisma validate
```
## Arquitectura del código
La aplicación separa responsabilidades por capa:
| Capa | Ruta | Responsabilidad |
|------|------|-----------------|
| UI / routes | `src/app` | Páginas, layouts y componentes por ruta |
| Server Actions | `src/actions` | Boundary de servidor: auth, Zod, llamada a use-case, revalidación |
| Use-cases | `src/use-cases` | Reglas de negocio, coordinación multi-entidad y transacciones |
| Services | `src/services` | Acceso a datos/repositories Prisma; muchos aceptan `tx` opcional |
| Schemas | `src/schemas` | Validación Zod y tipos de formularios/actions |
| Types | `src/types` | Tipos compartidos y aliases de Prisma |
| Lib | `src/lib` | Infraestructura común: auth, prisma, paginate, security, constants, utils |
### Regla práctica
- Las **Actions** deben ser finas.
- Las reglas de negocio viven en **use-cases**.
- Los **services** no orquestan flujos: leen/escriben datos y aceptan transacciones cuando participan en una operación mayor.
## Estructura principal
```txt
src/
├── app/ # App Router de Next.js
│ ├── (auth)/ # Rutas de autenticación
│ └── login/
│ ├── (dashboard)/ # Rutas del dashboard
│ ├── (home)/ # Página principal
│ ├── assignments/ # Gestión de asignaciones
│ │ ├── import/ # Importación de datos
│ │ ├── inventory/ # Gestión de inventario
│ │ │ ├── assets/ # Activos serializados
├── categories/# Categorías
└── items/ # Ítems genéricos
│ │ ├── movements/ # Historial de movimientos
└── recipients/ # Gestión de destinatarios
│ └── api/ # API routes
│ ├── auth/ # Endpoints de autenticación
│ └── db/ # Endpoints de base de datos
├── components/ # Componentes React
│ ├── auth/ # Componentes de autenticación
│ ├── common/ # Componentes comunes
│ ├── forms/ # Componentes de formularios
│ ├── layout/ # Componentes de layout
│ └── ui/ # Componentes UI (Radix)
├── lib/ # Utilidades y configuración
│ ├── actions/ # Server Actions
│ ├── schemas/ # Schemas de validación Zod
│ └── types/ # Tipos TypeScript
├── services/ # Servicios de lógica de negocio
├── prisma/ # Schema y migraciones Prisma
│ ├── schema.prisma # Definición del modelo de datos
│ ├── migrations/ # Historial de migraciones
│ └── seed.ts # Datos iniciales
└── styles/ # Estilos globales
├── actions/ # Server Actions finas
├── app/ # Next.js App Router
├── (auth)/ # Login
│ ├── (dashboard)/ # Dashboard, inventario, asignaciones, importación, people
│ ├── api/ # API routes
└── forbidden/ # Página de acceso denegado
├── components/ # Componentes compartidos y UI
├── generated/ # Cliente Prisma generado, ignorado por Git
├── hooks/ # Hooks React
├── lib/ # Infraestructura y utilidades
├── schemas/ # Schemas Zod
├── services/ # Repositories Prisma / read models
├── styles/ # Estilos globales
├── types/ # Tipos compartidos
└── use-cases/ # Casos de uso transaccionales
prisma/
├── bootstrap-admin.ts # Crea/activa admin inicial si corresponde
├── migrations/ # Migraciones Prisma
├── schema.prisma # Modelo de datos
└── seed.ts # Entry point de seed
```
## 🔐 Seguridad
## Autenticación y autorización
- Autenticación mediante JWT
- Contraseñas hasheadas con bcrypt
- Validación de datos con Zod en cliente y servidor
- Protección de rutas según roles
- Variables de entorno para secretos
- Sanitización de inputs
- Login con NextAuth credentials.
- Passwords hasheadas con `bcryptjs`.
- Roles soportados: `ADMIN`, `MANAGER`, `STAFF`, `VIEWER`.
- `/people/*` requiere rol `ADMIN` para operaciones de gestión.
- Usuarios inactivos no pueden iniciar sesión.
## 🗃️ Modelo de datos
Helpers relevantes:
El sistema gestiona las siguientes entidades principales:
```txt
src/services/auth.service.ts
src/lib/auth.ts
src/proxy.ts
```
- **Users**: Usuarios del sistema con roles y permisos
- **Recipients**: Destinatarios/empleados que reciben asignaciones
- **Categories**: Categorías de productos
- **Items**: Ítems genéricos (sin número de serie)
- **Assets**: Activos individuales (con número de serie)
- **Assignments**: Asignaciones de ítems/activos a destinatarios
- **Movements**: Registro de todos los movimientos de inventario
## Modelo de datos
Ver `src/prisma/schema.prisma` para el esquema completo.
Entidades principales:
| Entidad | Descripción |
|---------|-------------|
| `User` | Usuarios del sistema, roles y estados de ciclo de vida |
| `Person` | Personas del organigrama; pueden vincularse a un `User` |
| `Category` | Categorías de inventario |
| `Item` | Ítems genéricos con stock |
| `Asset` | Activos serializados |
| `Assignment` | Asignaciones y devoluciones |
| `Movement` | Historial auditable de movimientos |
Ver el schema completo en:
```txt
prisma/schema.prisma
```
## Flujos importantes
### Asignaciones
- Crear asignación decrementa stock de forma transaccional.
- Devolver asignación incrementa stock si aplica y libera activo.
- Movimientos `ASSIGNMENT` y `RETURN` se crean dentro del use-case.
### Activos
- Crear activo disponible incrementa stock.
- Crear activo asignado crea asignación y movimiento asociado.
- Cambios de estado generan movimientos adecuados (`IN`, `OUT`, `ASSIGNMENT`, `RETURN`, `ADJUSTMENT`).
### Ítems
- `Item.name` es único.
- Crear item con stock inicial genera movimiento `IN`.
- El borrado es soft delete y se bloquea si hay stock o assets asociados.
### Usuarios
- Sólo `ADMIN` puede gestionar personas y usuarios.
- No se puede quitar el propio acceso admin.
- No se puede dejar el sistema sin admin activo.
- La protección de último admin usa transacción serializable con retry de conflictos Prisma `P2034`.
## Transición de arquitectura
La aplicación está migrando de un modelo separado de usuarios y destinatarios hacia una gestión unificada de personas:
| Antes | Después |
|-------|---------|
| `Recipient` | `Person` |
| Gestión en `/admin/users` | Gestión en `/people` |
| Usuarios y personas desvinculados | Persona puede vincularse a un `User` opcional |
Estado actual:
- El schema Prisma y la migración inicial reflejan el nuevo modelo.
- Las rutas y componentes de UI ya fueron migrados a `/people`.
## Testing
El proyecto tiene una base inicial de tests en tres niveles:
| Nivel | Comando | Cobertura |
|-------|---------|-----------|
| Unit | `bun run test:unit` | Schemas Zod, helpers de seguridad y helpers de roles auth |
| Integración | `bun run test:integration` | Use-cases principales contra PostgreSQL real con Testcontainers |
| E2E smoke | `bun run test:e2e` | Login, dashboard, admin users, inventory items y assignments con Playwright |
### Integration tests
Los tests de integración viven en:
```txt
tests/integration/
```
Usan PostgreSQL real mediante Testcontainers. El helper de DB:
1. levanta un contenedor PostgreSQL aislado;
2. setea `DATABASE_URL` antes de importar Prisma/use-cases;
3. aplica migraciones con `prisma migrate deploy`;
4. limpia tablas entre tests.
> Importante: `src/lib/prisma.ts` lee `DATABASE_URL` al importarse. En tests, configurá el entorno antes de importar `@/lib/prisma`, services o use-cases.
### E2E smoke tests
Los tests E2E viven en:
```txt
tests/e2e/
```
Playwright levanta una app real contra una DB Testcontainers aislada y crea un admin determinístico para el smoke test.
El server E2E usa `next dev --webpack`. Next 16 puede usar Turbopack por defecto y durante la configuración inicial emitió un panic compilando `/assignments`; para E2E automatizado se fuerza Webpack por estabilidad.
### Secuencia completa recomendada
Antes de subir cambios grandes, ejecutá:
```bash
bun run test && bun run test:e2e && bunx tsc --noEmit && bunx prisma validate
```
## Validación antes de subir cambios
Ejecutá al menos:
```bash
bunx tsc --noEmit
bunx prisma validate
```
Para cambios de Prisma:
```bash
bun run db:generate
bunx prisma validate
```
Para cambios de formato/lint:
```bash
bun run check
```
## Estado conocido
- La importación CSV actual es legacy y se mantiene por compatibilidad; está previsto rediseñarla.
- El cliente Prisma generado no se versiona; debe generarse antes de build/deploy.
+602 -7
View File
@@ -37,14 +37,23 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.1.10",
"@testcontainers/postgresql": "^12.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"prisma": "^7.8.0",
"tailwindcss": "^4.1.10",
"testcontainers": "^12.0.1",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"vitest": "^4.1.8",
},
},
},
@@ -56,18 +65,42 @@
"@prisma/client",
],
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@auth/core": ["@auth/core@0.39.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-McD8slui0oOA1pjR5sPjLPl5Zm//nLP/8T3kr8hxIsvNLvsiudYvPHhDFPjh1KcZ2nFxCkZmP6bRxaaPd/AnLA=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@base-ui/react": ["@base-ui/react@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.8", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw=="],
"@base-ui/utils": ["@base-ui/utils@0.2.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
@@ -86,6 +119,20 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.9", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.5", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.1", "", {}, "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q=="],
@@ -94,8 +141,14 @@
"@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.1", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -104,6 +157,10 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
@@ -158,6 +215,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
@@ -166,12 +225,18 @@
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="],
@@ -190,8 +255,14 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="],
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg=="],
"@prisma/client": ["@prisma/client@7.8.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.8.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw=="],
@@ -220,6 +291,26 @@
"@prisma/studio-core": ["@prisma/studio-core@0.27.3", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "chart.js": "4.5.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
@@ -340,6 +431,38 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -376,6 +499,30 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="],
"@testcontainers/postgresql": ["@testcontainers/postgresql@12.0.1", "", { "dependencies": { "testcontainers": "^12.0.1" } }, "sha512-6SyyduUM6lTAo4UwKG1aCochP6wTe0k7/W/m5uODZQMUrV3STk6/28Km3474JLGZdw/P793BUEF2IeU7rDyoEg=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
"@types/dockerode": ["@types/dockerode@4.0.1", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
"@types/papaparse": ["@types/papaparse@5.3.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg=="],
@@ -386,22 +533,100 @@
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
"@types/ssh2": ["@types/ssh2@0.5.52", "", { "dependencies": { "@types/node": "*", "@types/ssh2-streams": "*" } }, "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg=="],
"@types/ssh2-streams": ["@types/ssh2-streams@0.1.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
"@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
"@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
"@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
"@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="],
"archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "^10.0.0", "graceful-fs": "^4.2.0", "is-stream": "^2.0.1", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bare-events": ["bare-events@2.9.1", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg=="],
"bare-fs": ["bare-fs@4.7.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg=="],
"bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="],
"bare-path": ["bare-path@3.0.1", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ=="],
"bare-stream": ["bare-stream@2.13.1", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="],
"bare-url": ["bare-url@2.4.5", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
"better-result": ["better-result@2.9.2", "", {}, "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"byline": ["byline@5.0.0", "", {}, "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q=="],
"c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001723", "", {}, "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -412,80 +637,190 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"docker-compose": ["docker-compose@1.4.2", "", { "dependencies": { "yaml": "^2.2.2" } }, "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww=="],
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
"dockerode": ["dockerode@5.0.0", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4" } }, "sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="],
"get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="],
"giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="],
"graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
@@ -506,13 +841,31 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
@@ -520,10 +873,16 @@
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="],
"named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="],
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="],
@@ -532,14 +891,26 @@
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"oauth4webapi": ["oauth4webapi@3.5.3", "", {}, "sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ=="],
"obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
@@ -562,8 +933,14 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
"playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
"playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
@@ -580,10 +957,24 @@
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prisma": ["prisma@7.8.0", "", { "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
"properties-reader": ["properties-reader@3.0.1", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "mkdirp": "^3.0.1" } }, "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g=="],
"protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
@@ -596,24 +987,40 @@
"react-hook-form": ["react-hook-form@7.74.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -626,20 +1033,48 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"ssh-remote-port-forward": ["ssh-remote-port-forward@1.0.4", "", { "dependencies": { "@types/ssh2": "^0.5.48", "ssh2": "^1.4.0" } }, "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"streamx": ["streamx@2.27.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="],
@@ -648,12 +1083,44 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],
"tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
"testcontainers": ["testcontainers@12.0.1", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@types/dockerode": "^4.0.1", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", "docker-compose": "^1.4.2", "dockerode": "^5.0.0", "get-port": "^7.2.0", "proper-lockfile": "^4.1.2", "properties-reader": "^3.0.1", "ssh-remote-port-forward": "^1.0.4", "tar-fs": "^3.1.2", "tmp": "^0.2.6", "undici": "^7.25.0" } }, "sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw=="],
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.4.4", "", { "dependencies": { "tldts-core": "^7.4.4" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g=="],
"tldts-core": ["tldts-core@7.4.4", "", {}, "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg=="],
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.3.4", "", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici": ["undici@7.27.1", "", {}, "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg=="],
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -664,18 +1131,72 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
"vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="],
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="],
@@ -770,6 +1291,8 @@
"@radix-ui/react-toolbar/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
@@ -782,15 +1305,33 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@types/papaparse/@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"c12/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"docker-modem/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"dockerode/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"radix-ui/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -816,6 +1357,24 @@
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"vite/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@ampproject/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@radix-ui/react-accordion/@radix-ui/react-collapsible/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
@@ -842,10 +1401,46 @@
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.7.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="],
"@tailwindcss/node/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
"@types/papaparse/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"dockerode/tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"dockerode/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"vite/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"vite/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.1", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="],
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"dockerode/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
}
}
-1
View File
@@ -38,7 +38,6 @@ services:
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
AUTH_SECRET: ${AUTH_SECRET}
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-"true"}
ADMIN_USERNAME: ${ADMIN_USERNAME:-"admin"}
ADMIN_EMAIL: ${ADMIN_EMAIL:-"admin@localhost"}
ADMIN_NAME: ${ADMIN_NAME:-"Administrator"}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
-1
View File
@@ -2,7 +2,6 @@ import type { NextConfig } from "next"
const nextConfig: NextConfig = {
/* config options here */
}
export default nextConfig
+15 -1
View File
@@ -11,6 +11,11 @@
"lint": "biome lint --write",
"format": "biome format --write",
"check": "biome check --write",
"test": "bunx vitest run",
"test:unit": "bunx vitest run tests/unit --passWithNoTests",
"test:integration": "bun run db:generate && bunx vitest run tests/integration",
"test:e2e": "bun run db:generate && bunx playwright test",
"test:coverage": "bunx vitest run --coverage",
"db:push": "bunx prisma db push",
"db:migrate": "bunx prisma migrate dev",
"db:migrate:reset": "bunx prisma migrate reset",
@@ -52,14 +57,23 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.1.10",
"@testcontainers/postgresql": "^12.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"prisma": "^7.8.0",
"tailwindcss": "^4.1.10",
"testcontainers": "^12.0.1",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^4.1.8"
},
"trustedDependencies": [
"@prisma/client",
+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,
},
})
+96 -27
View File
@@ -1,19 +1,28 @@
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 = {
username: string
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 username = process.env.ADMIN_USERNAME ?? "admin"
const email = process.env.ADMIN_EMAIL ?? "admin@localhost"
const email = process.env.ADMIN_EMAIL ?? "admin@local.host"
const name = process.env.ADMIN_NAME ?? "Administrator"
const password = process.env.ADMIN_PASSWORD
@@ -22,7 +31,6 @@ function getBootstrapAdminInput(): BootstrapAdminInput {
}
return {
username,
email,
name,
password: password ?? "admin",
@@ -31,37 +39,98 @@ function getBootstrapAdminInput(): BootstrapAdminInput {
export async function bootstrapAdmin(client: typeof prisma) {
const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false"
const existingAdmin = await client.user.findFirst({
if (!enabled) return
const admin = getBootstrapAdminInput()
const email = normalizeEmail(admin.email)
const { firstName, lastName } = splitName(admin.name)
const existingUser = await client.user.findUnique({
where: {
role: "ADMIN",
isActive: true,
emailNormalized: email,
},
select: {
id: true,
passwordHash: true,
activatedAt: true,
person: {
select: {
id: true,
},
},
},
})
if (existingAdmin || !enabled) return
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,
},
},
},
})
const admin = getBootstrapAdminInput()
await client.user.upsert({
where: {
email: admin.email,
},
update: {
role: "ADMIN",
isActive: true,
},
create: {
name: admin.name,
username: admin.username,
email: admin.email,
role: "ADMIN",
password: await getPasswordHash(admin.password),
isActive: 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() {
@@ -2,23 +2,49 @@
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'MANAGER', 'STAFF', 'VIEWER');
-- CreateEnum
CREATE TYPE "RecipientDepartment" AS ENUM ('IT', 'ENGINEERING', 'LOGISTICS', 'TRAFFIC', 'DRIVER', 'ADMINISTRATION', 'SALES', 'OTHER');
CREATE TYPE "UserStatus" AS ENUM ('INVITED', 'ACTIVE', 'SUSPENDED', 'DISABLED');
-- CreateEnum
CREATE TYPE "ItemStatus" AS ENUM ('AVAILABLE', 'ASSIGNED', 'RESERVED', 'IN_REPAIR', 'BROKEN', 'STOLEN', 'DISPOSED');
CREATE TYPE "PersonDepartment" AS ENUM ('IT', 'ENGINEERING', 'LOGISTICS', 'TRAFFIC', 'DRIVER', 'ADMINISTRATION', 'SALES', 'OTHER');
-- CreateEnum
CREATE TYPE "MovementType" AS ENUM ('IN', 'OUT', 'ASSIGNMENT', 'RETURN', 'ADJUSTMENT', 'DELETED');
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" TEXT NOT NULL,
"username" TEXT NOT NULL,
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"emailNormalized" TEXT NOT NULL,
"passwordHash" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'STAFF',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"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,
@@ -26,42 +52,60 @@ CREATE TABLE "User" (
);
-- CreateTable
CREATE TABLE "Recipient" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
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" "RecipientDepartment",
"department" "PersonDepartment",
"email" TEXT,
"phone" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"userId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Recipient_pkey" PRIMARY KEY ("id")
CONSTRAINT "Person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"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" TEXT NOT NULL,
"id" UUID NOT NULL,
"sku" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"categoryId" TEXT NOT NULL,
"trackingType" "ItemTrackingType" NOT NULL,
"status" "ItemStatus" NOT NULL DEFAULT 'ACTIVE',
"categoryId" UUID NOT NULL,
"stock" INTEGER NOT NULL DEFAULT 0,
"minStock" INTEGER,
"maxStock" INTEGER,
"targetStock" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
@@ -71,29 +115,38 @@ CREATE TABLE "Item" (
-- CreateTable
CREATE TABLE "Asset" (
"id" TEXT NOT NULL,
"itemId" TEXT,
"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,
"status" "ItemStatus" NOT NULL DEFAULT 'AVAILABLE',
"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" TEXT NOT NULL,
"quantity" INTEGER,
"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,
"itemId" TEXT,
"assetId" TEXT,
"recipientId" TEXT,
"assignmentDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returnDate" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdById" UUID NOT NULL,
"closedById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
@@ -101,122 +154,601 @@ CREATE TABLE "Assignment" (
);
-- CreateTable
CREATE TABLE "Movement" (
"id" TEXT NOT NULL,
"type" "MovementType" NOT NULL DEFAULT 'IN',
CREATE TABLE "AssignmentStockLine" (
"id" UUID NOT NULL,
"assignmentId" UUID NOT NULL,
"itemId" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"details" TEXT,
"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,
"itemId" TEXT,
"assetId" TEXT,
"previousStock" INTEGER,
"newStock" INTEGER,
"recipientId" TEXT,
"assignmentId" TEXT,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Movement_pkey" PRIMARY KEY ("id")
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_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_emailNormalized_key" ON "User"("emailNormalized");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "User_status_idx" ON "User"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Recipient_username_key" ON "Recipient"("username");
CREATE INDEX "User_deletedAt_idx" ON "User"("deletedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Recipient_email_key" ON "Recipient"("email");
CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
-- CreateIndex
CREATE INDEX "Recipient_lastName_firstName_idx" ON "Recipient"("lastName", "firstName");
CREATE UNIQUE INDEX "UserInvitation_tokenHash_key" ON "UserInvitation"("tokenHash");
-- CreateIndex
CREATE INDEX "Recipient_department_idx" ON "Recipient"("department");
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_name_idx" ON "Category"("name");
CREATE INDEX "Category_deletedAt_idx" ON "Category"("deletedAt");
-- CreateIndex
CREATE INDEX "Item_categoryId_idx" ON "Item"("categoryId");
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_serialNumber_idx" ON "Asset"("serialNumber");
-- CreateIndex
CREATE INDEX "Asset_itemId_idx" ON "Asset"("itemId");
CREATE INDEX "Asset_itemId_status_idx" ON "Asset"("itemId", "status");
-- CreateIndex
CREATE INDEX "Asset_status_idx" ON "Asset"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Assignment_assetId_key" ON "Assignment"("assetId");
CREATE INDEX "Asset_createdAt_idx" ON "Asset"("createdAt");
-- CreateIndex
CREATE INDEX "Assignment_itemId_idx" ON "Assignment"("itemId");
CREATE INDEX "Asset_deletedAt_idx" ON "Asset"("deletedAt");
-- CreateIndex
CREATE INDEX "Assignment_assetId_idx" ON "Assignment"("assetId");
CREATE INDEX "Assignment_personId_status_idx" ON "Assignment"("personId", "status");
-- CreateIndex
CREATE INDEX "Assignment_recipientId_idx" ON "Assignment"("recipientId");
CREATE INDEX "Assignment_personId_assignedAt_idx" ON "Assignment"("personId", "assignedAt");
-- CreateIndex
CREATE INDEX "Assignment_createdBy_idx" ON "Assignment"("createdBy");
CREATE INDEX "Assignment_status_assignedAt_idx" ON "Assignment"("status", "assignedAt");
-- CreateIndex
CREATE INDEX "Movement_itemId_idx" ON "Movement"("itemId");
CREATE INDEX "Assignment_dueAt_idx" ON "Assignment"("dueAt");
-- CreateIndex
CREATE INDEX "Movement_assetId_idx" ON "Movement"("assetId");
CREATE INDEX "Assignment_createdById_createdAt_idx" ON "Assignment"("createdById", "createdAt");
-- CreateIndex
CREATE INDEX "Movement_recipientId_idx" ON "Movement"("recipientId");
CREATE INDEX "AssignmentStockLine_assignmentId_idx" ON "AssignmentStockLine"("assignmentId");
-- CreateIndex
CREATE INDEX "Movement_type_idx" ON "Movement"("type");
CREATE INDEX "AssignmentStockLine_itemId_createdAt_idx" ON "AssignmentStockLine"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "Movement_userId_idx" ON "Movement"("userId");
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 SET NULL ON UPDATE CASCADE;
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_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
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_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
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_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AssignmentStockLine" ADD CONSTRAINT "AssignmentStockLine_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Movement" ADD CONSTRAINT "Movement_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssignmentStockLine" ADD CONSTRAINT "AssignmentStockLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Movement" ADD CONSTRAINT "Movement_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssignmentStockReturn" ADD CONSTRAINT "AssignmentStockReturn_assignmentLineId_fkey" FOREIGN KEY ("assignmentLineId") REFERENCES "AssignmentStockLine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Movement" ADD CONSTRAINT "Movement_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssignmentStockReturn" ADD CONSTRAINT "AssignmentStockReturn_receivedById_fkey" FOREIGN KEY ("receivedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Movement" ADD CONSTRAINT "Movement_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssignmentAssetLine" ADD CONSTRAINT "AssignmentAssetLine_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Movement" ADD CONSTRAINT "Movement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AssignmentAssetLine" ADD CONSTRAINT "AssignmentAssetLine_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssignmentAssetLine" ADD CONSTRAINT "AssignmentAssetLine_returnedById_fkey" FOREIGN KEY ("returnedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryMovement" ADD CONSTRAINT "InventoryMovement_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryMovement" ADD CONSTRAINT "InventoryMovement_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovementLine" ADD CONSTRAINT "StockMovementLine_movementId_fkey" FOREIGN KEY ("movementId") REFERENCES "InventoryMovement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovementLine" ADD CONSTRAINT "StockMovementLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetMovementLine" ADD CONSTRAINT "AssetMovementLine_movementId_fkey" FOREIGN KEY ("movementId") REFERENCES "InventoryMovement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetMovementLine" ADD CONSTRAINT "AssetMovementLine_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAlert" ADD CONSTRAINT "StockAlert_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAlert" ADD CONSTRAINT "StockAlert_acknowledgedById_fkey" FOREIGN KEY ("acknowledgedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- =====================================================
-- USER INVITATION / ACTIVATION
-- =====================================================
ALTER TABLE "User"
ADD CONSTRAINT "User_invited_without_password"
CHECK (
"status" <> 'INVITED'
OR "passwordHash" IS NULL
);
ALTER TABLE "User"
ADD CONSTRAINT "User_active_requires_password"
CHECK (
"status" <> 'ACTIVE'
OR "passwordHash" IS NOT NULL
);
ALTER TABLE "User"
ADD CONSTRAINT "User_active_requires_activation_date"
CHECK (
"status" <> 'ACTIVE'
OR "activatedAt" IS NOT NULL
);
ALTER TABLE "User"
ADD CONSTRAINT "User_activation_date_after_invitation"
CHECK (
"activatedAt" IS NULL
OR "invitedAt" IS NULL
OR "activatedAt" >= "invitedAt"
);
ALTER TABLE "User"
ADD CONSTRAINT "User_password_changed_after_invitation"
CHECK (
"passwordChangedAt" IS NULL
OR "invitedAt" IS NULL
OR "passwordChangedAt" >= "invitedAt"
);
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_expiry_after_creation"
CHECK ("expiresAt" > "createdAt");
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_accepted_or_revoked"
CHECK (
"acceptedAt" IS NULL
OR "revokedAt" IS NULL
);
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_accepted_after_creation"
CHECK (
"acceptedAt" IS NULL
OR "acceptedAt" >= "createdAt"
);
ALTER TABLE "UserInvitation"
ADD CONSTRAINT "UserInvitation_revoked_after_creation"
CHECK (
"revokedAt" IS NULL
OR "revokedAt" >= "createdAt"
);
CREATE UNIQUE INDEX "UserInvitation_active_user_key"
ON "UserInvitation" ("userId")
WHERE "acceptedAt" IS NULL
AND "revokedAt" IS NULL;
-- =====================================================
-- ITEM STOCK
-- =====================================================
ALTER TABLE "Item"
ADD CONSTRAINT "Item_stock_non_negative"
CHECK ("stock" >= 0);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_min_stock_non_negative"
CHECK (
"minStock" IS NULL
OR "minStock" >= 0
);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_target_stock_non_negative"
CHECK (
"targetStock" IS NULL
OR "targetStock" >= 0
);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_target_not_below_minimum"
CHECK (
"minStock" IS NULL
OR "targetStock" IS NULL
OR "targetStock" >= "minStock"
);
ALTER TABLE "Item"
ADD CONSTRAINT "Item_serialized_stock_zero"
CHECK (
"trackingType" <> 'SERIALIZED'
OR "stock" = 0
);
-- =====================================================
-- ASSET DATA
-- =====================================================
ALTER TABLE "Asset"
ADD CONSTRAINT "Asset_purchase_price_non_negative"
CHECK (
"purchasePrice" IS NULL
OR "purchasePrice" >= 0
);
ALTER TABLE "Asset"
ADD CONSTRAINT "Asset_warranty_date_valid"
CHECK (
"warrantyEndsAt" IS NULL
OR "purchaseDate" IS NULL
OR "warrantyEndsAt" >= "purchaseDate"
);
ALTER TABLE "Asset"
ADD CONSTRAINT "Asset_retired_date_valid"
CHECK (
"retiredAt" IS NULL
OR "retiredAt" >= "createdAt"
);
-- =====================================================
-- ASSIGNMENTS
-- =====================================================
ALTER TABLE "Assignment"
ADD CONSTRAINT "Assignment_due_date_valid"
CHECK (
"dueAt" IS NULL
OR "dueAt" >= "assignedAt"
);
ALTER TABLE "Assignment"
ADD CONSTRAINT "Assignment_closed_date_valid"
CHECK (
"closedAt" IS NULL
OR "closedAt" >= "assignedAt"
);
-- =====================================================
-- QUANTITY ASSIGNMENTS
-- =====================================================
ALTER TABLE "AssignmentStockLine"
ADD CONSTRAINT "AssignmentStockLine_quantity_positive"
CHECK ("quantity" > 0);
ALTER TABLE "AssignmentStockLine"
ADD CONSTRAINT "AssignmentStockLine_returned_non_negative"
CHECK ("returnedQuantity" >= 0);
ALTER TABLE "AssignmentStockLine"
ADD CONSTRAINT "AssignmentStockLine_returned_not_greater"
CHECK ("returnedQuantity" <= "quantity");
ALTER TABLE "AssignmentStockReturn"
ADD CONSTRAINT "AssignmentStockReturn_quantity_positive"
CHECK ("quantity" > 0);
-- =====================================================
-- SERIALIZED ASSET ASSIGNMENTS
-- =====================================================
ALTER TABLE "AssignmentAssetLine"
ADD CONSTRAINT "AssignmentAssetLine_return_date_valid"
CHECK (
"returnedAt" IS NULL
OR "returnedAt" >= "assignedAt"
);
ALTER TABLE "AssignmentAssetLine"
ADD CONSTRAINT "AssignmentAssetLine_return_data_consistent"
CHECK (
(
"returnedAt" IS NULL
AND "returnedById" IS NULL
AND "returnStatus" IS NULL
)
OR
(
"returnedAt" IS NOT NULL
AND "returnedById" IS NOT NULL
AND "returnStatus" IS NOT NULL
)
);
CREATE UNIQUE INDEX "AssignmentAssetLine_active_asset_key"
ON "AssignmentAssetLine" ("assetId")
WHERE "returnedAt" IS NULL;
-- =====================================================
-- STOCK MOVEMENTS
-- =====================================================
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_stock_consistency"
CHECK (
"newStock" = "previousStock" + "stockDelta"
);
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_previous_stock_non_negative"
CHECK ("previousStock" >= 0);
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_new_stock_non_negative"
CHECK ("newStock" >= 0);
ALTER TABLE "StockMovementLine"
ADD CONSTRAINT "StockMovementLine_delta_not_zero"
CHECK ("stockDelta" <> 0);
-- =====================================================
-- STOCK ALERTS
-- =====================================================
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_available_stock_non_negative"
CHECK ("availableStock" >= 0);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_minimum_stock_non_negative"
CHECK ("minimumStock" >= 0);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_suggested_purchase_non_negative"
CHECK (
"suggestedPurchase" IS NULL
OR "suggestedPurchase" >= 0
);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_acknowledgement_consistent"
CHECK (
(
"acknowledgedAt" IS NULL
AND "acknowledgedById" IS NULL
)
OR
(
"acknowledgedAt" IS NOT NULL
AND "acknowledgedById" IS NOT NULL
)
);
ALTER TABLE "StockAlert"
ADD CONSTRAINT "StockAlert_resolution_date_valid"
CHECK (
"resolvedAt" IS NULL
OR "resolvedAt" >= "triggeredAt"
);
CREATE UNIQUE INDEX "StockAlert_active_item_trigger_key"
ON "StockAlert" ("itemId", "trigger")
WHERE "status" IN ('OPEN', 'ACKNOWLEDGED');
@@ -1,2 +0,0 @@
-- CreateIndex
CREATE UNIQUE INDEX "Item_name_key" ON "Item"("name");
@@ -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;
+521 -125
View File
@@ -14,6 +14,10 @@ datasource db {
provider = "postgresql"
}
// ======================================================
// USERS
// ======================================================
enum UserRole {
ADMIN
MANAGER
@@ -21,164 +25,556 @@ enum UserRole {
VIEWER
}
enum UserStatus {
INVITED
ACTIVE
SUSPENDED
DISABLED
}
model User {
id String @id @default(uuid())
username String @unique
name String
email String @unique
password String
role UserRole @default(STAFF)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements Movement[]
assignments Assignment[]
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])
}
enum RecipientDepartment {
IT
ENGINEERING
LOGISTICS
TRAFFIC
DRIVER
ADMINISTRATION
SALES
OTHER
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])
}
model Recipient {
id String @id @default(uuid())
username String @unique
firstName String
lastName String
department RecipientDepartment?
email String? @unique
phone String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// ======================================================
// 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[]
movements Movement[]
@@index([lastName, firstName])
@@index([department])
@@index([teamId, deletedAt])
@@index([teamId])
@@index([deletedAt])
}
model Category {
id String @id @default(uuid())
name String @unique
description String?
isActive Boolean @default(true)
items Item[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model Team {
id String @id @default(uuid(7)) @db.Uuid
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
people Person[]
}
// ======================================================
// CATALOG
// ======================================================
enum ItemTrackingType {
QUANTITY
SERIALIZED
}
enum ItemStatus {
AVAILABLE
ASSIGNED
RESERVED
IN_REPAIR
BROKEN
STOLEN
DISPOSED
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())
name String @unique
id String @id @default(uuid(7)) @db.Uuid
sku String @unique
name String
description String?
categoryId String
category Category @relation(fields: [categoryId], references: [id])
stock Int @default(0)
minStock Int?
maxStock Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
movements Movement[]
assignments Assignment[]
assets Asset[]
@@index([categoryId])
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())
itemId String?
item Item? @relation(fields: [itemId], references: [id])
serialNumber String @unique
deliveryNote String?
status ItemStatus @default(AVAILABLE)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements Movement[]
assignment Assignment?
id String @id @default(uuid(7)) @db.Uuid
@@index([serialNumber])
@@index([itemId])
/**
* 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())
quantity Int?
notes String?
itemId String?
item Item? @relation(fields: [itemId], references: [id])
assetId String? @unique
asset Asset? @relation(fields: [assetId], references: [id])
recipientId String?
recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade, onUpdate: Cascade)
assignmentDate DateTime @default(now())
returnDate DateTime?
createdBy String
createdUser User @relation(fields: [createdBy], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movement Movement[]
id String @id @default(uuid(7)) @db.Uuid
@@index([itemId])
@@index([assetId])
@@index([recipientId])
@@index([createdBy])
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])
}
enum MovementType {
IN
OUT
// ======================================================
// 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
DELETED
STATUS_CHANGE
DISPOSAL
INITIAL_LOAD
}
model Movement {
id String @id @default(uuid())
type MovementType @default(IN)
quantity Int
details String?
notes String?
itemId String?
item Item? @relation(fields: [itemId], references: [id])
assetId String?
asset Asset? @relation(fields: [assetId], references: [id])
previousStock Int?
newStock Int?
recipientId String?
recipient Recipient? @relation(fields: [recipientId], references: [id])
assignmentId String?
assignment Assignment? @relation(fields: [assignmentId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([itemId])
@@index([assetId])
@@index([recipientId])
@@index([type])
@@index([userId])
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])
}
+31 -15
View File
@@ -2,11 +2,13 @@
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,
createAssetSchema,
type UpdateAssetFormType,
updateAssetSchema,
} from "@/schemas/asset.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
@@ -15,15 +17,19 @@ import {
} from "@/use-cases/asset.use-cases"
export async function createAssetAction(formData: CreateAssetFormType) {
try {
const validatedFields = createAssetSchema.safeParse(formData)
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,
}
if (!validatedFields.success) {
return {
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const userId = await getAuthenticatedUserId()
const result = await createAssetUseCase({
@@ -32,7 +38,10 @@ export async function createAssetAction(formData: CreateAssetFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeAssetFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/inventory/assets")
@@ -42,19 +51,23 @@ export async function createAssetAction(formData: CreateAssetFormType) {
return {
success: true,
message: "Asset created successfully",
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error creating asset",
message: copy.actions.createFailure,
}
}
}
export async function updateAssetAction(formData: UpdateAssetFormType) {
const validatedFields = updateAssetSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
const validatedFields = buildUpdateAssetSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
@@ -71,7 +84,10 @@ export async function updateAssetAction(formData: UpdateAssetFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeAssetFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/inventory/assets")
@@ -81,13 +97,13 @@ export async function updateAssetAction(formData: UpdateAssetFormType) {
return {
success: true,
message: "Asset updated successfully",
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Error updating asset",
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)),
]),
)
}
+120 -61
View File
@@ -1,6 +1,17 @@
"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,
@@ -8,104 +19,152 @@ import {
updateAssignmentUseCase,
} from "@/use-cases/assignment.use-cases"
import {
assignmentSchema,
type CreateAssignmentFormType,
type ReturnAssignmentFormType,
type UpdateAssignmentFormType,
updateAssignmentSchema,
} from "@/schemas/assignment.schema"
export async function createAssignment(formData: CreateAssignmentFormType) {
const createdBy = await getAuthenticatedUserId()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const validatedFields = assignmentSchema.safeParse({
...formData,
createdBy,
})
const validatedFields = buildCreateAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const result = await createAssignmentUseCase({
...validatedFields.data,
actorId: createdBy,
})
if (!result.success) {
return result
}
revalidatePath("/assignments")
return {
success: true,
message: "Assignment created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
errors: { error: ["Error creating assignment"] },
}
}
}
export async function updateAssignment(formData: UpdateAssignmentFormType) {
const validatedFields = updateAssignmentSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
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 result = await updateAssignmentUseCase({
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
return {
...result,
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
}
}
revalidatePath("/assignments")
return {
success: true,
message: "Assignment updated successfully",
success: true as const,
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
errors: { error: ["Error updating assignment"] },
success: false as const,
message: copy.actions.createFailure,
}
}
}
export async function returnAssignment(formData: ReturnAssignmentFormType) {
const { id } = formData
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,
id: validatedFields.data.id,
actorId: userId,
returns: validatedFields.data.returns,
})
if (!result.success) {
return {
...result,
message: "Error returning assignment",
errors: localizeAssignmentFieldErrors(result.errors, copy.actions),
message: copy.actions.returnFailure,
}
}
@@ -113,6 +172,6 @@ export async function returnAssignment(formData: ReturnAssignmentFormType) {
return {
success: true as const,
message: "Assignment returned successfully",
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)),
]),
)
}
+2 -2
View File
@@ -6,11 +6,11 @@ import { signIn } from "@/lib/auth"
import type { SignInFormType } from "@/schemas/auth.schema"
export async function signInAction(values: SignInFormType) {
const { username, password } = values
const { email, password } = values
try {
await signIn("credentials", {
username,
email,
password,
redirect: false,
})
+39 -17
View File
@@ -1,12 +1,13 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateCategorySchema,
buildUpdateCategorySchema,
type CreateCategoryFormType,
createCategorySchema,
type UpdateCategoryFormType,
updateCategorySchema,
} from "@/schemas/category.schema"
import {
createCategoryUseCase,
@@ -14,13 +15,19 @@ import {
updateCategoryUseCase,
} from "@/use-cases/category.use-cases"
import { localizeCategoryFieldErrors } from "./category.messages"
export async function createCategoryAction(formData: CreateCategoryFormType) {
const validatedFields = createCategorySchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
const validatedFields = buildCreateCategorySchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -28,34 +35,42 @@ export async function createCategoryAction(formData: CreateCategoryFormType) {
const result = await createCategoryUseCase(validatedFields.data)
if (!result.success) {
return result
return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/inventory/categories")
return {
success: true,
message: "Category created successfully",
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Failed to create category",
message: copy.actions.createFailure,
errors: {
name: ["Category already exists"],
name: [copy.actions.duplicateName],
},
}
}
}
export async function updateCategoryAction(formData: UpdateCategoryFormType) {
const validatedFields = updateCategorySchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
const validatedFields = buildUpdateCategorySchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -63,25 +78,31 @@ export async function updateCategoryAction(formData: UpdateCategoryFormType) {
const result = await updateCategoryUseCase(validatedFields.data)
if (!result.success) {
return result
return {
...result,
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/inventory/categories")
return {
success: true,
message: "Category updated successfully",
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false,
message: "Failed to update category",
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 {
@@ -90,7 +111,8 @@ export async function deleteCategoryAction(formData: FormData) {
if (!result.success) {
return {
...result,
message: "Failed to delete category",
errors: localizeCategoryFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
}
}
@@ -98,13 +120,13 @@ export async function deleteCategoryAction(formData: FormData) {
return {
success: true as const,
message: "Category deleted successfully",
message: copy.actions.deleteSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: "Failed to delete category",
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 }
}
+24 -24
View File
@@ -12,14 +12,14 @@ import { getAuthenticatedUserId } from "@/services/auth.service"
import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import { RecipientService } from "@/services/recipient.service"
import { PersonService } from "@/services/person.service"
import type {
Asset,
Assignment,
Category,
ImportItem,
Item,
Recipient,
Person,
} from "@/types"
export async function importItems(formData: ImportFormType) {
@@ -153,7 +153,6 @@ export async function importItems(formData: ImportFormType) {
category,
deliveryNote,
assigned,
username,
firstName,
lastName,
} = row
@@ -178,10 +177,6 @@ export async function importItems(formData: ImportFormType) {
importErrors.push(`Row ${index + 2}: Delivery note must be a string`)
}
if (username && typeof username !== "string") {
importErrors.push(`Row ${index + 2}: Username must be a string`)
}
if (firstName && typeof firstName !== "string") {
importErrors.push(`Row ${index + 2}: First name must be a string`)
}
@@ -214,7 +209,6 @@ export async function importItems(formData: ImportFormType) {
category: row.category?.trim() || "",
deliveryNote: row.deliveryNote?.trim() || "",
assigned: row.assigned?.trim() === "true",
username: row.username?.trim() || "",
firstName: row.firstName?.trim() || "",
lastName: row.lastName?.trim() || "",
})
@@ -229,7 +223,6 @@ export async function importItems(formData: ImportFormType) {
category,
deliveryNote,
assigned,
username,
firstName,
lastName,
} = item
@@ -238,7 +231,7 @@ export async function importItems(formData: ImportFormType) {
let newItem: Item | null = null
let newAsset: Asset | null = null
let newCategory: Category | null = null
let newRecipient: Recipient | null = null
let newPerson: Person | null = null
let newAssignment: Assignment | null = null
const existingCategory = categoryId
@@ -255,7 +248,13 @@ export async function importItems(formData: ImportFormType) {
if (!existingItem) {
newItem = await ItemService.create({
sku: name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
name,
trackingType: "QUANTITY",
stock: assigned ? 0 : stock || 0,
category: {
connect: { id: categoryId ? categoryId : newCategory?.id || "" },
@@ -290,22 +289,23 @@ export async function importItems(formData: ImportFormType) {
}
if (assigned && firstName && lastName) {
const finalUsername =
username || `${firstName.toLowerCase()[0]}${lastName.toLowerCase()}`
const existingRecipient =
await RecipientService.findByUsername(finalUsername)
const existingPerson = firstName
? await PersonService.findAllPaginated({
search: firstName,
page: 0,
pageSize: 1,
})
: null
if (!existingRecipient) {
newRecipient = await RecipientService.create({
username: finalUsername,
if (!existingPerson || existingPerson.data.length === 0) {
newPerson = await PersonService.create({
firstName,
lastName,
email: undefined,
phone: "",
department: "OTHER",
})
} else {
newRecipient = existingRecipient
newPerson = existingPerson.data[0]
}
newAssignment = await AssignmentService.create({
@@ -313,7 +313,7 @@ export async function importItems(formData: ImportFormType) {
notes: deliveryNote || "",
itemId: newItem?.id || "",
assetId: newAsset?.id || "",
recipientId: newRecipient?.id || "",
personId: newPerson?.id || "",
assignmentDate: new Date(),
createdBy: userId,
})
@@ -323,16 +323,16 @@ export async function importItems(formData: ImportFormType) {
assetId: newAsset?.id || undefined,
quantity: stock || 1,
type: assigned ? "ASSIGNMENT" : "IN",
itemId: newItem?.id || undefined,
recipientId: newRecipient?.id || undefined,
itemId: assigned ? undefined : newItem?.id || undefined,
personId: newPerson?.id || undefined,
}
if (newAssignment?.id) {
movementData.assignmentId = newAssignment.id
}
if (newRecipient?.id) {
movementData.recipientId = newRecipient.id
if (newPerson?.id) {
movementData.personId = newPerson.id
}
await MovementService.create({
+34 -15
View File
@@ -1,11 +1,13 @@
"use server"
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateItemSchema,
buildUpdateItemSchema,
type CreateItemFormType,
createItemSchema,
type UpdateItemFormType,
updateItemSchema,
} from "@/schemas/item.schema"
import { getAuthenticatedUserId } from "@/services/auth.service"
import {
@@ -14,12 +16,16 @@ import {
updateItemUseCase,
} from "@/use-cases/item.use-cases"
import { localizeItemFieldErrors } from "./item.messages"
export async function createItemAction(formData: CreateItemFormType) {
const validatedFields = createItemSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const validatedFields = buildCreateItemSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -32,7 +38,11 @@ export async function createItemAction(formData: CreateItemFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath("/inventory/items")
@@ -40,22 +50,24 @@ export async function createItemAction(formData: CreateItemFormType) {
return {
success: true,
message: "Item created successfully!",
message: copy.actions.createSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
error: "Error creating item",
error: copy.actions.createFailure,
}
}
}
export async function updateItemAction(formData: UpdateItemFormType) {
const validatedFields = updateItemSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
const validatedFields = buildUpdateItemSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -68,7 +80,11 @@ export async function updateItemAction(formData: UpdateItemFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath("/inventory/items")
@@ -76,17 +92,19 @@ export async function updateItemAction(formData: UpdateItemFormType) {
return {
success: true,
message: "Item updated successfully!",
message: copy.actions.updateSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
error: "Failed to update item",
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 {
@@ -95,7 +113,8 @@ export async function deleteItemAction(formData: FormData) {
if (!result.success) {
return {
...result,
message: "Failed to delete item",
errors: localizeItemFieldErrors(result.errors, copy.actions),
message: copy.actions.deleteFailure,
}
}
@@ -103,13 +122,13 @@ export async function deleteItemAction(formData: FormData) {
return {
success: true as const,
message: "Item deleted successfully!",
message: copy.actions.deleteSuccess,
}
} catch (error) {
console.error("Database error:", error)
return {
success: false as const,
message: "Failed to delete item",
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)),
]),
)
}
-76
View File
@@ -1,76 +0,0 @@
"use server"
import { revalidatePath } from "next/cache"
import {
type CreateRecipientFormType,
createRecipientSchema,
type UpdateRecipientFormType,
updateRecipientSchema,
} from "@/schemas/recipient.schema"
import {
createRecipientUseCase,
updateRecipientUseCase,
} from "@/use-cases/recipient.use-cases"
export async function createNewRecipient(formData: CreateRecipientFormType) {
const validatedFields = createRecipientSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const result = await createRecipientUseCase(validatedFields.data)
if (!result.success) {
return result
}
revalidatePath("/recipients")
return {
success: true,
message: "Recipient created successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
message: "Failed to create recipient",
}
}
}
export async function updateRecipient(formData: UpdateRecipientFormType) {
const validatedFields = updateRecipientSchema.safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
}
}
try {
const result = await updateRecipientUseCase(validatedFields.data)
if (!result.success) {
return result
}
revalidatePath("/recipients")
return {
success: true,
message: "Recipient updated successfully",
}
} catch (error) {
console.error("Database error:", error)
return {
message: "Failed to update recipient",
}
}
}
+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)),
]),
)
}
+54 -30
View File
@@ -2,16 +2,16 @@
import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import {
buildCreateUserSchema,
buildResetUserPasswordSchema,
buildSetUserActiveSchema,
buildUpdateUserSchema,
type CreateUserFormType,
createUserSchema,
type ResetUserPasswordFormType,
resetUserPasswordSchema,
type SetUserActiveFormType,
setUserActiveSchema,
type UpdateUserFormType,
updateUserSchema,
} from "@/schemas/user.schema"
import { requireRole } from "@/services/auth.service"
import {
@@ -21,17 +21,19 @@ import {
updateUserUseCase,
} from "@/use-cases/user.use-cases"
const USERS_PATH = "/admin/users"
import { localizeUserFieldErrors } from "./user.messages"
const USERS_PATH = "/people"
export async function createUserAction(formData: CreateUserFormType) {
await requireRole("ADMIN")
const validatedFields = createUserSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildCreateUserSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -39,22 +41,27 @@ export async function createUserAction(formData: CreateUserFormType) {
const result = await createUserUseCase(validatedFields.data)
if (!result.success) {
return result
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: "User created successfully" }
return { success: true, message: copy.actions.createSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to create user" }
return { success: false, message: copy.actions.createFailure }
}
}
export async function updateUserAction(formData: UpdateUserFormType) {
const session = await requireRole("ADMIN")
const validatedFields = updateUserSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildUpdateUserSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) {
return {
@@ -70,22 +77,29 @@ export async function updateUserAction(formData: UpdateUserFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: "User updated successfully" }
return { success: true, message: copy.actions.updateSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to update user" }
return { success: false, message: copy.actions.updateFailure }
}
}
export async function setUserActiveAction(formData: SetUserActiveFormType) {
const session = await requireRole("ADMIN")
const validatedFields = setUserActiveSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildSetUserActiveSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
@@ -101,29 +115,35 @@ export async function setUserActiveAction(formData: SetUserActiveFormType) {
})
if (!result.success) {
return result
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.toggleStatusFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: "User status updated successfully" }
return { success: true, message: copy.actions.toggleStatusSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to update user status" }
return { success: false, message: copy.actions.toggleStatusFailure }
}
}
export async function resetUserPasswordAction(
formData: ResetUserPasswordFormType,
) {
await requireRole("ADMIN")
const validatedFields = resetUserPasswordSchema.safeParse(formData)
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = buildResetUserPasswordSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
@@ -131,14 +151,18 @@ export async function resetUserPasswordAction(
const result = await resetUserPasswordUseCase(validatedFields.data)
if (!result.success) {
return result
return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.resetPasswordFailure,
}
}
revalidatePath(USERS_PATH)
return { success: true, message: "Password reset successfully" }
return { success: true, message: copy.actions.resetPasswordSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: "Failed to reset password" }
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)
}),
]),
)
}
@@ -6,9 +6,14 @@ import { useState } from "react"
import { useForm } from "react-hook-form"
import { signInAction } from "@/actions/auth.actions"
import { Button } from "@/components/ui/button"
import type { Dictionary } from "@/i18n/dictionaries"
import { type SignInFormType, signInSchema } from "@/schemas/auth.schema"
export default function SignInForm() {
type SignInFormProps = {
copy: Dictionary["login"]
}
export default function SignInForm({ copy }: SignInFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get("callbackUrl")
@@ -17,7 +22,7 @@ export default function SignInForm() {
const { register, handleSubmit, formState } = useForm<SignInFormType>({
resolver: zodResolver(signInSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
})
@@ -37,19 +42,19 @@ export default function SignInForm() {
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<label className="flex flex-col gap-1">
Username
{copy.emailLabel}
<input
{...register("username")}
name="username"
{...register("email")}
name="email"
type="text"
className="border-input w-full rounded-md border-2 p-2"
/>
{formState.errors.username && (
<p className="text-error">{formState.errors.username.message}</p>
{formState.errors.email && (
<p className="text-error">{formState.errors.email.message}</p>
)}
</label>
<label className="flex flex-col gap-1">
Password
{copy.passwordLabel}
<input
{...register("password")}
name="password"
@@ -61,7 +66,7 @@ export default function SignInForm() {
)}
</label>
{error && <p className="text-error">{error}</p>}
<Button type="submit">Sign In</Button>
<Button type="submit">{copy.submitLabel}</Button>
</form>
)
}
+16 -3
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation"
import { LanguageSwitcher } from "@/components/i18n/language-switcher"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth"
import SignInForm from "./_components/login-form"
@@ -10,15 +12,26 @@ export default async function LoginPage() {
if (session) redirect("/")
const { dictionary, locale } = await getI18n()
const copy = dictionary.login
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm space-y-3">
<div className="flex justify-end">
<LanguageSwitcher
activeLocale={locale}
copy={dictionary.common.languageSwitcher}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardTitle>
<h1>{copy.title}</h1>
</CardTitle>
</CardHeader>
<CardContent>
<SignInForm />
<SignInForm copy={copy} />
</CardContent>
</Card>
</div>
@@ -3,11 +3,13 @@ import Link from "next/link"
export default function Card({
title,
total,
countLabel,
icon,
href,
}: {
title: string
total: number
countLabel: string
icon: React.ReactNode
href: string
}) {
@@ -18,7 +20,9 @@ export default function Card({
<div className="mr-4">{icon}</div>
<div>
<h3 className="text-lg font-medium">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">Total: {total}</p>
<p className="text-muted-foreground mt-2 text-sm">
{countLabel}: {total}
</p>
</div>
</div>
</div>
+15 -9
View File
@@ -1,21 +1,25 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import { PersonService } from "@/services/person.service"
import Card from "./_components/card"
export default async function Home() {
const { dictionary } = await getI18n()
const copy = dictionary.dashboardHome
const totalItems = await ItemService.findAllItemsCount()
const totalAssets = await AssetService.findAllAssetsCount()
const totalRecipients = await RecipientService.findAllRecipientsCount()
const totalPeople = await PersonService.findAllPeopleCount()
return (
<div className="container mx-auto p-4">
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
<h1 className="mb-4 text-2xl font-bold">{copy.heading}</h1>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card
title="Total Items"
title={copy.cards.items.title}
total={totalItems}
countLabel={copy.cards.items.countLabel}
href="/inventory/items"
icon={
<svg
@@ -37,8 +41,9 @@ export default async function Home() {
}
/>
<Card
title="Total Assets"
title={copy.cards.assets.title}
total={totalAssets}
countLabel={copy.cards.assets.countLabel}
href="/inventory/assets"
icon={
<svg
@@ -60,9 +65,10 @@ export default async function Home() {
}
/>
<Card
title="Total Recipients"
total={totalRecipients}
href="/recipients"
title={copy.cards.people.title}
total={totalPeople}
countLabel={copy.cards.people.countLabel}
href="/people"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -71,7 +77,7 @@ export default async function Home() {
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="total-recipients"
aria-label="total-people"
>
<path
strokeLinecap="round"
@@ -1,32 +0,0 @@
import { notFound } from "next/navigation"
import { getUserProfileById } from "@/services/user.service"
import EditUserForm from "../../_components/edit.user.form"
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
export default async function EditUserPage({
params,
}: {
params: Promise<{ userId: string }>
}) {
const { userId } = await params
const user = await getUserProfileById(userId)
if (!user) {
notFound()
}
return (
<div className="flex flex-col gap-8">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit User</h1>
</div>
<EditUserForm user={user} />
<section className="flex flex-col gap-4 border-t pt-6">
<h2 className="text-xl font-semibold">Reset password</h2>
<ResetUserPasswordForm userId={user.id} />
</section>
</div>
)
}
@@ -1,141 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateUserAction } from "@/actions/user.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
type UpdateUserFormType,
updateUserSchema,
} from "@/schemas/user.schema"
import type { UserWithoutPassword } from "@/services/user.service"
export default function EditUserForm({ user }: { user: UserWithoutPassword }) {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateUserFormType>({
resolver: zodResolver(updateUserSchema),
defaultValues: {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
role: user.role,
isActive: user.isActive,
},
})
const onSubmit = async (formData: UpdateUserFormType) => {
const response = await updateUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UpdateUserFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/admin/users")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<UserTextInput
error={errors.name?.message}
id="name"
label="Name"
placeholder="Full name"
register={register("name")}
/>
<UserTextInput
error={errors.username?.message}
id="username"
label="Username"
placeholder="username"
register={register("username")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
label="Email"
placeholder="user@example.com"
register={register("email")}
type="email"
/>
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
Role
</label>
<select
id="role"
{...register("role")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">Admin</option>
<option value="MANAGER">Manager</option>
<option value="STAFF">Staff</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("isActive")} />
Active user
</label>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update User
</SubmitButton>
</form>
)
}
function UserTextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
@@ -1,145 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import type { UseFormRegisterReturn } from "react-hook-form"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createUserAction } from "@/actions/user.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
type CreateUserFormType,
createUserSchema,
} from "@/schemas/user.schema"
export default function NewUserForm() {
const router = useRouter()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateUserFormType>({
resolver: zodResolver(createUserSchema),
defaultValues: {
role: "STAFF",
isActive: true,
},
})
const onSubmit = async (formData: CreateUserFormType) => {
const response = await createUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof CreateUserFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/admin/users")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<UserTextInput
error={errors.name?.message}
id="name"
label="Name"
placeholder="Full name"
register={register("name")}
/>
<UserTextInput
error={errors.username?.message}
id="username"
label="Username"
placeholder="username"
register={register("username")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
label="Email"
placeholder="user@example.com"
register={register("email")}
type="email"
/>
<UserTextInput
error={errors.password?.message}
id="password"
label="Password"
placeholder="Minimum 8 characters"
register={register("password")}
type="password"
/>
<RoleSelect register={register("role")} />
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create User
</SubmitButton>
</form>
)
}
function UserTextInput({
error,
id,
label,
placeholder,
register,
type = "text",
}: {
error?: string
id: string
label: string
placeholder: string
register: UseFormRegisterReturn
type?: string
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={id} className="mb-2 block text-lg">
{label}
</label>
<input
type={type}
id={id}
placeholder={placeholder}
{...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
/>
{error && <p className="text-error">{error}</p>}
</div>
)
}
function RoleSelect({ register }: { register: UseFormRegisterReturn }) {
return (
<div className="flex flex-col gap-2">
<label htmlFor="role" className="mb-2 block text-lg">
Role
</label>
<select
id="role"
{...register}
className="w-full rounded-lg border px-4 py-2"
>
<option value="ADMIN">Admin</option>
<option value="MANAGER">Manager</option>
<option value="STAFF">Staff</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
)
}
@@ -1,75 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { resetUserPasswordAction } from "@/actions/user.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
type ResetUserPasswordFormType,
resetUserPasswordSchema,
} from "@/schemas/user.schema"
export default function ResetUserPasswordForm({ userId }: { userId: string }) {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<ResetUserPasswordFormType>({
resolver: zodResolver(resetUserPasswordSchema),
defaultValues: {
id: userId,
},
})
const onSubmit = async (formData: ResetUserPasswordFormType) => {
const response = await resetUserPasswordAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof ResetUserPasswordFormType, {
type: "server",
message,
})
toast.error(message)
})
})
return
}
if (response?.success) {
toast.success(response.message)
reset({ id: userId, password: "" })
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="password" className="mb-2 block text-lg">
New password
</label>
<input
type="password"
id="password"
placeholder="Minimum 8 characters"
{...register("password")}
className={`w-full rounded-lg border px-4 py-2 ${errors.password ? "border-error" : ""}`}
/>
{errors.password && (
<p className="text-error">{errors.password.message}</p>
)}
</div>
<SubmitButton
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Reset Password
</SubmitButton>
</form>
)
}
@@ -1,12 +0,0 @@
import NewUserForm from "../_components/new.user.form"
export default function NewUserPage() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New User</h1>
</div>
<NewUserForm />
</div>
)
}
-96
View File
@@ -1,96 +0,0 @@
import { Pencil } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getUsers } from "@/services/user.service"
export default async function UsersPage(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: users, totalPages } = await getUsers({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Users"
link="/admin/users/new"
search={search}
data={users}
/>
{users.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
No users found.
</div>
</div>
)}
{users.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Name
</th>
<th scope="col" className="p-4">
Username
</th>
<th scope="col" className="p-4">
Email
</th>
<th scope="col" className="p-4">
Role
</th>
<th scope="col" className="p-4">
Status
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="p-4">{user.name}</td>
<td className="p-4">{user.username}</td>
<td className="p-4">{user.email}</td>
<td className="p-4">{user.role}</td>
<td className="p-4">
{user.isActive ? "Active" : "Inactive"}
</td>
<td className="p-4">
<Link href={`/admin/users/${user.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
@@ -1,8 +1,8 @@
import type { UpdateAssignmentFormType } from "@/schemas/assignment.schema"
import { getI18n } from "@/i18n/server"
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 { PersonService } from "@/services/person.service"
import type { Item } from "@/types"
import AssignmentForm from "../../_components/edit.assignment.form"
@@ -13,12 +13,14 @@ export default async function EditAssignmentPage({
}) {
const { assignmentId } = await params
const assignment = await AssignmentService.findById(assignmentId)
const recipients = await RecipientService.findAll()
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>Assignment not found</div>
return <div>{copy.edit.notFound}</div>
}
let assignmentItem: Item = {} as Item
@@ -29,12 +31,27 @@ export default async function EditAssignmentPage({
}
return (
<div>
<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
recipients={recipients}
people={people}
items={items}
assets={assets}
initialData={assignment as UpdateAssignmentFormType}
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,38 +2,57 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateAssignment } from "@/actions/assignment.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildUpdateAssignmentSchema,
type UpdateAssignmentFormType,
updateAssignmentSchema,
} from "@/schemas/assignment.schema"
import type { Asset, Item, Recipient } from "@/types"
import type { Asset, Item, Person } from "@/types"
type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"]
type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"]
interface Props {
recipients: Recipient[]
people: Person[]
items: Item[]
assets: Asset[]
initialData: UpdateAssignmentFormType
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
}
export default function EditAssignmentForm({
recipients,
people,
items,
assets,
initialData,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) {
const router = useRouter()
const schema = useMemo(
() => buildUpdateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssignmentFormType>({
resolver: zodResolver(updateAssignmentSchema),
resolver: zodResolver(schema),
defaultValues: {
...initialData,
id: initialData.id || undefined,
@@ -65,29 +84,29 @@ export default function EditAssignmentForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
</label>
<select
id="recipientId"
{...register("recipientId")}
id="personId"
{...register("personId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.recipientId ? "border-error" : ""
errors.personId ? "border-error" : ""
}`}
>
{recipients.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
</option>
))}
</select>
{errors.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
{errors.personId && (
<p className="text-error">{errors.personId.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="itemId" className="mb-2 block text-lg">
Item
{formCopy.itemLabel}
</label>
<select
id="itemId"
@@ -106,7 +125,7 @@ export default function EditAssignmentForm({
</div>
<div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg">
Asset
{formCopy.assetLabel}
</label>
<select
id="assetId"
@@ -115,7 +134,7 @@ export default function EditAssignmentForm({
errors.assetId ? "border-error" : ""
}`}
>
<option value="">Select an asset</option>
<option value="">{formCopy.assetPlaceholder}</option>
{itemId
? assets.map((asset) => (
<option key={asset.id} value={asset.id}>
@@ -130,7 +149,7 @@ export default function EditAssignmentForm({
</div>
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
Quantity
{formCopy.quantityLabel}
</label>
<input
type="number"
@@ -138,6 +157,7 @@ export default function EditAssignmentForm({
disabled={!itemId || assets.length > 0}
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
placeholder={formCopy.quantityPlaceholder}
defaultValue={1}
{...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${
@@ -149,11 +169,12 @@ export default function EditAssignmentForm({
)}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (assets.length > 0 && !assetId)}
>
Update Assignment
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
@@ -1,39 +1,63 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createAssignment } from "@/actions/assignment.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import type { Dictionary } from "@/i18n/dictionaries"
import {
buildCreateAssignmentSchema,
type CreateAssignmentFormType,
createAssignmentSchema,
} from "@/schemas/assignment.schema"
import type { Asset, Item, Recipient } from "@/types"
import type { Asset, Item, Person } from "@/types"
type AssignmentFormCopy = Dictionary["inventory"]["assignments"]["form"]
type AssignmentSchemaCopy = Dictionary["inventory"]["assignments"]["schema"]
interface Props {
recipients: Recipient[]
people: Person[]
items: Item[]
assets: Asset[]
formCopy: AssignmentFormCopy
schemaCopy: AssignmentSchemaCopy
submitButtonCopy: SubmitButtonCopy
}
export default function CreateAssignmentForm({
recipients,
people,
items,
assets,
formCopy,
schemaCopy,
submitButtonCopy,
}: Props) {
const searchParams = useSearchParams()
const personId = searchParams.get("personId")
const router = useRouter()
const schema = useMemo(
() => buildCreateAssignmentSchema(schemaCopy),
[schemaCopy],
)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssignmentFormType>({
resolver: zodResolver(createAssignmentSchema),
resolver: zodResolver(schema),
mode: "onSubmit",
defaultValues: {
personId: personId ?? "",
quantity: 1,
},
})
const itemId = watch("itemId")
@@ -44,7 +68,10 @@ export default function CreateAssignmentForm({
}, [assets, itemId])
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) {
Object.values(response.errors as Record<string, string[]>).forEach(
@@ -63,30 +90,31 @@ export default function CreateAssignmentForm({
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
</label>
<select
id="recipientId"
{...register("recipientId")}
id="personId"
disabled={!!personId}
{...register("personId")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.recipientId ? "border-error" : ""
errors.personId ? "border-error" : ""
}`}
>
<option value="">Select a recipient</option>
{recipients.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
<option value="">{formCopy.personPlaceholder}</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
</option>
))}
</select>
{errors.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
{errors.personId && (
<p className="text-error">{errors.personId.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="itemId" className="mb-2 block text-lg">
Item
{formCopy.itemLabel}
</label>
<select
id="itemId"
@@ -95,7 +123,7 @@ export default function CreateAssignmentForm({
errors.itemId ? "border-error" : ""
}`}
>
<option value="">Select an item</option>
<option value="">{formCopy.itemPlaceholder}</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -107,7 +135,7 @@ export default function CreateAssignmentForm({
{itemId && itemAssets.length !== 0 && (
<div className="flex flex-col gap-2">
<label htmlFor="assetId" className="mb-2 block text-lg">
Asset
{formCopy.assetLabel}
</label>
<select
id="assetId"
@@ -119,7 +147,7 @@ export default function CreateAssignmentForm({
: ""
}`}
>
<option value="">Select an asset</option>
<option value="">{formCopy.assetPlaceholder}</option>
{itemId
? itemAssets.map((asset) => (
<option key={asset.id} value={asset.id}>
@@ -133,34 +161,32 @@ export default function CreateAssignmentForm({
)}
</div>
)}
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
Quantity
</label>
<input
type="number"
id="quantity"
disabled={!itemId || itemAssets.length > 0}
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
defaultValue={1}
{...register("quantity")}
className={`w-full rounded-lg border px-4 py-2 ${
!itemId || itemAssets.length > 0
? "border-gray-300 bg-gray-100"
: ""
}`}
/>
{errors.quantity && (
<p className="text-error">{errors.quantity.message}</p>
)}
</div>
{itemId && itemAssets.length === 0 && (
<div className="flex flex-col gap-2">
<label htmlFor="quantity" className="mb-2 block text-lg">
{formCopy.quantityLabel}
</label>
<input
type="number"
id="quantity"
min={1}
max={itemId ? items.find((item) => item.id === itemId)?.stock : 0}
placeholder={formCopy.quantityPlaceholder}
{...register("quantity")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors.quantity && (
<p className="text-error">{errors.quantity.message}</p>
)}
</div>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!itemId || (itemAssets.length > 0 && !assetId)}
>
Create Assignment
{formCopy.createSubmit}
</SubmitButton>
</form>
)
@@ -2,50 +2,209 @@
import { ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { returnAssignment } from "@/actions/assignment.actions"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import type { Dictionary } from "@/i18n/dictionaries"
import type { ReturnAssignmentFormType } from "@/schemas/assignment.schema"
type PartialReturnCopy = Dictionary["inventory"]["assignments"]["partialReturn"]
const defaultPartialReturnCopy: PartialReturnCopy = {
title: "Devolver artículo",
quantity: "Cantidad",
quantityPlaceholder: "1",
notes: "Notas",
notesPlaceholder: "Notas opcionales",
submit: "Devolver",
cancel: "Cancelar",
maxQuantity: "Máximo: {max}",
errorConcurrent:
"La devolución fue modificada por otro usuario. Recarga e inténtalo de nuevo.",
errorGeneric: "Error al procesar la devolución",
}
export default function ReturnButton({
assignmentId,
ariaLabel,
assignmentLineId,
remainingQuantity,
copy = defaultPartialReturnCopy,
}: {
assignmentId: string
ariaLabel: string
assignmentLineId?: string
remainingQuantity?: number
copy?: PartialReturnCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [open, setOpen] = useState(false)
const [quantity, setQuantity] = useState(1)
const [notes, setNotes] = useState("")
const [errorKey, setErrorKey] = useState<
"errorConcurrent" | "errorGeneric" | null
>(null)
const isQuantityMode =
assignmentLineId !== undefined && remainingQuantity !== undefined
const isOverMax = isQuantityMode && quantity > remainingQuantity
const canSubmit = isQuantityMode
? quantity >= 1 && quantity <= remainingQuantity && !isPending
: !isPending
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setQuantity(1)
setNotes("")
setErrorKey(null)
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!canSubmit) return
setErrorKey(null)
const formData: ReturnAssignmentFormType = isQuantityMode
? {
id: assignmentId,
returns: [
{
assignmentLineId,
quantity,
notes: notes.trim() || undefined,
},
],
}
: { id: assignmentId }
const handleReturn = (formData: ReturnAssignmentFormType) => {
startTransition(async () => {
const response = await returnAssignment(formData)
if (!response.success && response.errors?.id) {
toast.error(response.errors.id[0])
if (response.success) {
setOpen(false)
setQuantity(1)
setNotes("")
toast.success(response.message)
router.refresh()
return
}
if (response.success) {
toast.success(response.message)
router.refresh()
if (response.errors?.error?.includes("errorConcurrent")) {
setErrorKey("errorConcurrent")
} else {
toast.error(response.message ?? "Unknown error")
setErrorKey("errorGeneric")
}
})
}
return (
<form action={() => handleReturn({ id: assignmentId })} className="w-full">
<input type="hidden" name="id" value={assignmentId} />
<Button
type="submit"
className="btn btn-error"
size="icon"
variant="outline"
disabled={isPending}
>
<ArrowLeft />
</Button>
</form>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
type="button"
className="btn btn-error"
size="icon"
variant="outline"
aria-label={ariaLabel}
disabled={isPending}
>
<ArrowLeft />
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription className="sr-only">
{copy.title}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{isQuantityMode && (
<>
<div className="grid gap-2">
<label
htmlFor={`quantity-${assignmentId}`}
className="text-sm font-medium"
>
{copy.quantity}
</label>
<Input
id={`quantity-${assignmentId}`}
type="number"
min={1}
max={remainingQuantity}
value={quantity}
onChange={(event) =>
setQuantity(Number(event.target.value))
}
placeholder={copy.quantityPlaceholder}
aria-invalid={isOverMax || undefined}
/>
{isOverMax && (
<p className="text-destructive text-sm">
{copy.maxQuantity.replace(
"{max}",
String(remainingQuantity),
)}
</p>
)}
</div>
<div className="grid gap-2">
<label
htmlFor={`notes-${assignmentId}`}
className="text-sm font-medium"
>
{copy.notes}
</label>
<textarea
id={`notes-${assignmentId}`}
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder={copy.notesPlaceholder}
className="min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30"
/>
</div>
</>
)}
{errorKey && (
<p className="text-destructive text-sm" role="alert">
{copy[errorKey]}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
{copy.cancel}
</Button>
<Button type="submit" disabled={!canSubmit}>
{copy.submit}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+18 -3
View File
@@ -1,15 +1,30 @@
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import { PersonService } from "@/services/person.service"
import AssignmentForm from "../_components/new.assignment.form"
export default async function NewAssignmentPage() {
const recipients = await RecipientService.findAll()
const people = await PersonService.findAll()
const items = await ItemService.findAllWithStock()
const assets = await AssetService.findAllAvailable()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
return (
<AssignmentForm recipients={recipients} items={items} assets={assets} />
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<AssignmentForm
people={people}
items={items}
assets={assets}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
+38 -15
View File
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service"
import ReturnButton from "./_components/return.button"
@@ -18,39 +19,43 @@ export default async function AssignmentsPage(props: {
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const { data: assignments, totalPages } =
await AssignmentService.findAllWithRecipientPaginated({
await AssignmentService.findAllWithPersonPaginated({
page: currentPage,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Assignments"
title={copy.list.title}
link="/assignments/new"
searchable={true}
search={search}
data={assignments}
addLabel={copy.list.addLabel}
/>
{assignments.length === 0 && <div>No assignments found</div>}
{assignments.length === 0 && <div>{copy.list.empty}</div>}
{assignments.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Recipient
{copy.list.columns.person}
</th>
<th scope="col" className="p-4">
Item
{copy.list.columns.item}
</th>
<th scope="col" className="p-4">
Serial Number
{copy.list.columns.serialNumber}
</th>
<th scope="col" className="p-4">
Quantity
{copy.list.columns.quantity}
</th>
<th scope="col" className="p-4">
Actions
{copy.list.columns.actions}
</th>
</tr>
</thead>
@@ -59,11 +64,11 @@ export default async function AssignmentsPage(props: {
<tr key={assignment.id} className="border-b">
<td className="p-4">
<Link
href={`/recipients/${assignment?.recipient?.id}`}
href={`/people/${assignment?.person?.id}`}
className="hover:underline"
>
{assignment?.recipient?.firstName}{" "}
{assignment?.recipient?.lastName}
{assignment?.person?.firstName}{" "}
{assignment?.person?.lastName}
</Link>
</td>
<td className="p-4">
@@ -75,10 +80,19 @@ export default async function AssignmentsPage(props: {
</Link>
</td>
<td className="p-4">
{assignment?.asset?.serialNumber || "N/A"}
{assignment?.asset?.serialNumber ||
copy.fallback.missingValue}
</td>
<td className="p-4">
{assignment?.quantity}
{assignment.status === "PARTIALLY_RETURNED" &&
assignment.remainingQuantity !== undefined
? `${copy.remaining.label}: ${copy.remaining.value
.replace(
"{remaining}",
String(assignment.remainingQuantity),
)
.replace("{total}", String(assignment.quantity))}`
: assignment?.quantity}
</td>
<td className="p-4">
<div className="flex gap-2">
@@ -86,11 +100,20 @@ export default async function AssignmentsPage(props: {
href={`/assignments/${assignment.id}/edit`}
passHref
>
<Button variant="outline">
<Button
variant="outline"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
<ReturnButton assignmentId={assignment.id} />
<ReturnButton
assignmentId={assignment.id}
ariaLabel={copy.list.actions.return}
assignmentLineId={assignment.assignmentLineId}
remainingQuantity={assignment.remainingQuantity}
copy={copy.partialReturn}
/>
</div>
</td>
</tr>
@@ -6,14 +6,19 @@ import type { ChangeEvent } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { importItems } from "@/actions/import.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { type ImportFormType, importSchema } from "@/schemas/import.schema"
import type { CategorySummary } from "@/types"
export default function ImportForm({
categories,
submitButtonCopy,
}: {
categories: CategorySummary[]
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
@@ -95,6 +100,7 @@ export default function ImportForm({
)}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
disabled={!file}
+6 -1
View File
@@ -2,6 +2,7 @@ import { Download } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { ENVIRONMENT } from "@/lib/constants"
import { CategoryService } from "@/services/category.service"
@@ -9,6 +10,7 @@ import ImportForm from "./_components/import.form"
export default async function ImportPage() {
const categories = await CategoryService.findAllWithItemsCount()
const { dictionary } = await getI18n()
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
@@ -30,7 +32,10 @@ export default async function ImportPage() {
</Button>
</Link>
</div>
<ImportForm categories={categories} />
<ImportForm
categories={categories}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -1,8 +1,9 @@
"use server"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import { PersonService } from "@/services/person.service"
import type { AssetWithAssignment } from "@/types"
import EditAssetForm from "../../_components/edit.asset.form"
@@ -14,22 +15,28 @@ export default async function EditAssetPage({
}) {
const { assetId } = await params
const items = await ItemService.findAll()
const recipients = await RecipientService.findAll()
const people = await PersonService.findAll()
const asset = await AssetService.findById(assetId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
if (!asset) {
return <div>Asset not found</div>
return <div>{copy.edit.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Asset</h1>
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div>
<EditAssetForm
items={items}
recipients={recipients}
people={people}
asset={asset as unknown as AssetWithAssignment}
formCopy={copy.form}
schemaCopy={copy.schema}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
@@ -0,0 +1,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,34 +2,53 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ITEM_STATUS } from "@/lib/constants"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { UPDATE_ASSET_STATUSES } from "@/lib/constants"
import {
buildUpdateAssetSchema,
type UpdateAssetFormType,
updateAssetSchema,
} from "@/schemas/asset.schema"
import type {
AssetWithAssignment,
Item,
Recipient,
Person,
UpdateAssetStatus,
} from "@/types"
import type {
AssetFormCopy,
AssetSchemaCopy,
AssetStatusCopy,
} from "./asset.copy"
interface EditAssetFormProps {
asset: AssetWithAssignment
items: Item[]
recipients: Recipient[]
people: Person[]
formCopy: AssetFormCopy
schemaCopy: AssetSchemaCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
}
export default function EditAssetForm({
asset,
items,
recipients,
people,
formCopy,
schemaCopy,
statusCopy,
submitButtonCopy,
}: EditAssetFormProps) {
const router = useRouter()
const schema = useMemo(() => buildUpdateAssetSchema(schemaCopy), [schemaCopy])
const {
register,
@@ -38,14 +57,14 @@ export default function EditAssetForm({
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<UpdateAssetFormType>({
resolver: zodResolver(updateAssetSchema),
resolver: zodResolver(schema),
defaultValues: {
id: asset.id,
itemId: asset.itemId ?? undefined,
serialNumber: asset.serialNumber,
deliveryNote: asset.deliveryNote ?? undefined,
status: asset.status as UpdateAssetStatus,
recipientId: asset.assignment?.recipientId ?? undefined,
personId: asset.assignment?.personId ?? undefined,
},
shouldFocusError: true,
mode: "onSubmit",
@@ -70,7 +89,7 @@ export default function EditAssetForm({
}
if (response?.success) {
toast.success("Asset updated successfully")
toast.success(response.message)
router.push(`/inventory/assets`)
}
}
@@ -78,15 +97,16 @@ export default function EditAssetForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
<label htmlFor="itemId" className="mb-2 block text-lg">
{formCopy.itemLabel}
</label>
<select
id="itemId"
defaultValue={asset.itemId}
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a item:</option>
<option value="">{formCopy.itemPlaceholder}</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -99,12 +119,13 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number
{formCopy.serialNumberLabel}
</label>
<input
type="text"
id="serialNumber"
placeholder="Serial number"
placeholder={formCopy.serialNumberPlaceholder}
defaultValue={asset.serialNumber}
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -112,14 +133,110 @@ export default function EditAssetForm({
<p className="text-error">{errors?.serialNumber?.message}</p>
)}
</div>
<div>
<label htmlFor="assetTag" className="mb-2 block text-lg">
{formCopy.assetTagLabel}
</label>
<input
type="text"
id="assetTag"
placeholder={formCopy.assetTagPlaceholder}
defaultValue={asset.assetTag ?? undefined}
{...register("assetTag")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.assetTag && (
<p className="text-error">{errors.assetTag.message}</p>
)}
</div>
<div>
<label htmlFor="manufacturer" className="mb-2 block text-lg">
{formCopy.manufacturerLabel}
</label>
<input
type="text"
id="manufacturer"
placeholder={formCopy.manufacturerPlaceholder}
defaultValue={asset.manufacturer ?? undefined}
{...register("manufacturer")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.manufacturer && (
<p className="text-error">{errors.manufacturer.message}</p>
)}
</div>
<div>
<label htmlFor="model" className="mb-2 block text-lg">
{formCopy.modelLabel}
</label>
<input
type="text"
id="model"
placeholder={formCopy.modelPlaceholder}
defaultValue={asset.model ?? undefined}
{...register("model")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.model && <p className="text-error">{errors.model.message}</p>}
</div>
<div>
<label htmlFor="purchaseDate" className="mb-2 block text-lg">
{formCopy.purchaseDateLabel}
</label>
<input
type="date"
id="purchaseDate"
placeholder={formCopy.purchaseDatePlaceholder}
defaultValue={asset.purchaseDate?.toISOString().slice(0, 10)}
{...register("purchaseDate")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchaseDate && (
<p className="text-error">{errors.purchaseDate.message}</p>
)}
</div>
<div>
<label htmlFor="purchasePrice" className="mb-2 block text-lg">
{formCopy.purchasePriceLabel}
</label>
<input
type="number"
step="0.01"
id="purchasePrice"
placeholder={formCopy.purchasePricePlaceholder}
defaultValue={asset.purchasePrice?.toString()}
{...register("purchasePrice")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchasePrice && (
<p className="text-error">{errors.purchasePrice.message}</p>
)}
</div>
<div>
<label htmlFor="warrantyEndsAt" className="mb-2 block text-lg">
{formCopy.warrantyEndsAtLabel}
</label>
<input
type="date"
id="warrantyEndsAt"
placeholder={formCopy.warrantyEndsAtPlaceholder}
defaultValue={asset.warrantyEndsAt?.toISOString().slice(0, 10)}
{...register("warrantyEndsAt")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.warrantyEndsAt && (
<p className="text-error">{errors.warrantyEndsAt.message}</p>
)}
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note
{formCopy.deliveryNoteLabel}
</label>
<input
type="text"
id="deliveryNote"
placeholder="Delivery note"
placeholder={formCopy.deliveryNotePlaceholder}
defaultValue={asset.deliveryNote ?? undefined}
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -129,17 +246,18 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
Status
{formCopy.statusLabel}
</label>
<select
id="status"
defaultValue={asset.status}
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
{Object.values(ITEM_STATUS).map((status) => (
<option value="">{formCopy.statusPlaceholder}</option>
{UPDATE_ASSET_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
{statusCopy[status]}
</option>
))}
</select>
@@ -149,31 +267,33 @@ export default function EditAssetForm({
</div>
{status === "ASSIGNED" && (
<div>
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
</label>
<select
id="recipientId"
{...register("recipientId")}
id="personId"
defaultValue={asset.assignment?.personId ?? undefined}
{...register("personId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a Recipient</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
<option value="">{formCopy.personPlaceholder}</option>
{people?.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
</option>
))}
</select>
{errors?.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
{errors?.personId && (
<p className="text-error">{errors.personId.message}</p>
)}
</div>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Asset
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
@@ -2,24 +2,46 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createAssetAction } from "@/actions/asset.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { ITEM_STATUS } from "@/lib/constants"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { CREATE_ASSET_STATUSES } from "@/lib/constants"
import {
buildCreateAssetSchema,
type CreateAssetFormType,
createAssetSchema,
} from "@/schemas/asset.schema"
import type { ItemWithoutStock, Recipient } from "@/types"
import type { ItemWithoutStock, Person } from "@/types"
import type {
AssetFormCopy,
AssetSchemaCopy,
AssetStatusCopy,
} from "./asset.copy"
interface NewAssetFormProps {
items: ItemWithoutStock[]
recipients: Recipient[]
people: Person[]
formCopy: AssetFormCopy
schemaCopy: AssetSchemaCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
}
export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
export default function NewAssetForm({
items,
people,
formCopy,
schemaCopy,
statusCopy,
submitButtonCopy,
}: NewAssetFormProps) {
const router = useRouter()
const schema = useMemo(() => buildCreateAssetSchema(schemaCopy), [schemaCopy])
const {
register,
@@ -28,7 +50,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
formState: { errors, isSubmitting, isSubmitSuccessful },
watch,
} = useForm<CreateAssetFormType>({
resolver: zodResolver(createAssetSchema),
resolver: zodResolver(schema),
defaultValues: {
status: "AVAILABLE",
},
@@ -55,7 +77,7 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
}
if (response?.success) {
toast.success("Asset created successfully")
toast.success(response.message)
router.push(`/inventory/assets`)
}
}
@@ -63,15 +85,15 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
<label htmlFor="itemId" className="mb-2 block text-lg">
{formCopy.itemLabel}
</label>
<select
id="itemId"
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a item:</option>
<option value="">{formCopy.itemPlaceholder}</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -84,12 +106,12 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number
{formCopy.serialNumberLabel}
</label>
<input
type="text"
id="serialNumber"
placeholder="Serial number"
placeholder={formCopy.serialNumberPlaceholder}
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -97,14 +119,103 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
<p className="text-error">{errors?.serialNumber?.message}</p>
)}
</div>
<div>
<label htmlFor="assetTag" className="mb-2 block text-lg">
{formCopy.assetTagLabel}
</label>
<input
type="text"
id="assetTag"
placeholder={formCopy.assetTagPlaceholder}
{...register("assetTag")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.assetTag && (
<p className="text-error">{errors.assetTag.message}</p>
)}
</div>
<div>
<label htmlFor="manufacturer" className="mb-2 block text-lg">
{formCopy.manufacturerLabel}
</label>
<input
type="text"
id="manufacturer"
placeholder={formCopy.manufacturerPlaceholder}
{...register("manufacturer")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.manufacturer && (
<p className="text-error">{errors.manufacturer.message}</p>
)}
</div>
<div>
<label htmlFor="model" className="mb-2 block text-lg">
{formCopy.modelLabel}
</label>
<input
type="text"
id="model"
placeholder={formCopy.modelPlaceholder}
{...register("model")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.model && <p className="text-error">{errors.model.message}</p>}
</div>
<div>
<label htmlFor="purchaseDate" className="mb-2 block text-lg">
{formCopy.purchaseDateLabel}
</label>
<input
type="date"
id="purchaseDate"
placeholder={formCopy.purchaseDatePlaceholder}
{...register("purchaseDate")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchaseDate && (
<p className="text-error">{errors.purchaseDate.message}</p>
)}
</div>
<div>
<label htmlFor="purchasePrice" className="mb-2 block text-lg">
{formCopy.purchasePriceLabel}
</label>
<input
type="number"
step="0.01"
id="purchasePrice"
placeholder={formCopy.purchasePricePlaceholder}
{...register("purchasePrice")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.purchasePrice && (
<p className="text-error">{errors.purchasePrice.message}</p>
)}
</div>
<div>
<label htmlFor="warrantyEndsAt" className="mb-2 block text-lg">
{formCopy.warrantyEndsAtLabel}
</label>
<input
type="date"
id="warrantyEndsAt"
placeholder={formCopy.warrantyEndsAtPlaceholder}
{...register("warrantyEndsAt")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.warrantyEndsAt && (
<p className="text-error">{errors.warrantyEndsAt.message}</p>
)}
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note
{formCopy.deliveryNoteLabel}
</label>
<input
type="text"
id="deliveryNote"
placeholder="Delivery note"
placeholder={formCopy.deliveryNotePlaceholder}
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -114,17 +225,17 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
Status
{formCopy.statusLabel}
</label>
<select
id="status"
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
{Object.values(ITEM_STATUS).map((status) => (
<option value="">{formCopy.statusPlaceholder}</option>
{CREATE_ASSET_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
{statusCopy[status]}
</option>
))}
</select>
@@ -134,31 +245,32 @@ export default function NewAssetForm({ items, recipients }: NewAssetFormProps) {
</div>
{status === "ASSIGNED" && (
<div>
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
<label htmlFor="personId" className="mb-2 block text-lg">
{formCopy.personLabel}
</label>
<select
id="recipientId"
{...register("recipientId")}
id="personId"
{...register("personId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a Recipient</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
<option value="">{formCopy.personPlaceholder}</option>
{people?.map((person) => (
<option key={person.id} value={person.id}>
{person.firstName} {person.lastName}
</option>
))}
</select>
{errors?.recipientId && (
<p className="text-error">{errors.recipientId.message}</p>
{errors?.personId && (
<p className="text-error">{errors.personId.message}</p>
)}
</div>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Asset
{formCopy.createSubmit}
</SubmitButton>
</form>
)
@@ -1,20 +1,30 @@
"use server"
import { getI18n } from "@/i18n/server"
import { ItemService } from "@/services/item.service"
import { RecipientService } from "@/services/recipient.service"
import { PersonService } from "@/services/person.service"
import NewAssetForm from "../_components/new.asset.form"
export default async function NewAssetPage() {
const items = await ItemService.findAllAssignable()
const recipients = await RecipientService.findAll()
const people = await PersonService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Asset</h1>
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<NewAssetForm items={items} recipients={recipients} />
<NewAssetForm
items={items}
people={people}
formCopy={copy.form}
schemaCopy={copy.schema}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
+77 -10
View File
@@ -4,8 +4,32 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import type {
AssetFallbackCopy,
AssetStatusCopy,
} from "./_components/asset.copy"
function formatAssetStatus(
status: string,
statusCopy: AssetStatusCopy,
fallbackCopy: AssetFallbackCopy,
) {
return status in statusCopy
? statusCopy[status as keyof AssetStatusCopy]
: fallbackCopy.unknownStatus
}
function formatDate(value: Date | null | undefined) {
return value ? value.toISOString().slice(0, 10) : "—"
}
function formatPrice(value: { toString(): string } | null | undefined) {
return value ? value.toString() : "—"
}
export default async function AssetsPage(props: {
searchParams?: Promise<{
page?: string
@@ -21,19 +45,23 @@ export default async function AssetsPage(props: {
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Assets"
title={copy.list.title}
link="/inventory/assets/new"
data={assets}
searchable={true}
search={search}
addLabel={copy.list.addLabel}
/>
{assets.length === 0 && currentPage === 1 && (
<div className="flex gap-4">
<div className="flex items-center justify-between gap-4">
No Assets found.
{copy.list.empty}
</div>
</div>
)}
@@ -43,19 +71,37 @@ export default async function AssetsPage(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Item Name
{copy.list.columns.item}
</th>
<th scope="col" className="p-4">
Category
{copy.list.columns.category}
</th>
<th scope="col" className="p-4">
Serial Number
{copy.list.columns.serialNumber}
</th>
<th scope="col" className="p-4">
Status
{copy.list.columns.assetTag}
</th>
<th scope="col" className="p-4">
Actions
{copy.list.columns.manufacturer}
</th>
<th scope="col" className="p-4">
{copy.list.columns.model}
</th>
<th scope="col" className="p-4">
{copy.list.columns.purchaseDate}
</th>
<th scope="col" className="p-4">
{copy.list.columns.purchasePrice}
</th>
<th scope="col" className="p-4">
{copy.list.columns.warrantyEndsAt}
</th>
<th scope="col" className="p-4">
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th>
</tr>
</thead>
@@ -65,10 +111,31 @@ export default async function AssetsPage(props: {
<td className="p-4">{asset.item?.name}</td>
<td className="p-4">{asset.item?.category?.name}</td>
<td className="p-4">{asset.serialNumber}</td>
<td className="p-4">{asset.status}</td>
<td className="p-4">{asset.assetTag ?? "—"}</td>
<td className="p-4">{asset.manufacturer ?? "—"}</td>
<td className="p-4">{asset.model ?? "—"}</td>
<td className="p-4">{formatDate(asset.purchaseDate)}</td>
<td className="p-4">{formatPrice(asset.purchasePrice)}</td>
<td className="p-4">{formatDate(asset.warrantyEndsAt)}</td>
<td className="p-4">
{formatAssetStatus(
asset.status,
copy.status,
copy.fallback,
)}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/assets/${asset.id}`} passHref>
<Button variant="outline" size="sm">
{copy.list.actions.view}
</Button>
</Link>
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
@@ -78,7 +145,7 @@ export default async function AssetsPage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<td colSpan={11} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import EditCategoryForm from "../../_components/edit.category.form"
@@ -11,6 +12,8 @@ export default async function EditCategoryPage({
}) {
const { categoryId } = await params
const category = await CategoryService.findById(categoryId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
if (!category) {
notFound()
@@ -19,9 +22,14 @@ export default async function EditCategoryPage({
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Category</h1>
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div>
<EditCategoryForm category={category} />
<EditCategoryForm
category={category}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -0,0 +1,6 @@
import type { Dictionary } from "@/i18n/dictionaries"
import type { CategorySchemaCopy } from "@/schemas/category.schema"
export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"]
export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"]
export type { CategorySchemaCopy }
@@ -7,10 +7,14 @@ import { toast } from "sonner"
import { deleteCategoryAction } from "@/actions/category.actions"
import { Button } from "@/components/ui/button"
import type { CategoryDeleteCopy } from "./category.copy"
export default function DeleteCategoryButton({
categoryId,
copy,
}: {
categoryId: string
copy: CategoryDeleteCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
@@ -28,7 +32,7 @@ export default function DeleteCategoryButton({
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? "Unknown error")
toast.error(response.message ?? copy.unknownError)
}
})
}
@@ -42,6 +46,7 @@ export default function DeleteCategoryButton({
size="icon"
variant="outline"
disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
>
<Trash />
</Button>
@@ -2,22 +2,37 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateCategoryAction } from "@/actions/category.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUpdateCategorySchema,
type UpdateCategoryFormType,
updateCategorySchema,
} from "@/schemas/category.schema"
import type { CategorySummary } from "@/types"
import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
export default function EditCategoryForm({
category,
formCopy,
schemaCopy,
submitButtonCopy,
}: {
category: CategorySummary
formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(
() => buildUpdateCategorySchema(schemaCopy),
[schemaCopy],
)
const {
register,
@@ -25,7 +40,7 @@ export default function EditCategoryForm({
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateCategoryFormType>({
resolver: zodResolver(updateCategorySchema),
resolver: zodResolver(schema),
defaultValues: {
id: category.id,
name: category.name,
@@ -59,12 +74,12 @@ export default function EditCategoryForm({
<input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg">
Name
{formCopy.nameLabel}
</label>
<input
type="text"
id="name"
placeholder="Category name"
placeholder={formCopy.namePlaceholder}
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : ""
@@ -73,10 +88,11 @@ export default function EditCategoryForm({
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Category
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
@@ -2,17 +2,34 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createCategoryAction } from "@/actions/category.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildCreateCategorySchema,
type CreateCategoryFormType,
createCategorySchema,
} from "@/schemas/category.schema"
import type { CategoryFormCopy, CategorySchemaCopy } from "./category.copy"
export default function NewCategoryForm() {
export default function NewCategoryForm({
formCopy,
schemaCopy,
submitButtonCopy,
}: {
formCopy: CategoryFormCopy
schemaCopy: CategorySchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(
() => buildCreateCategorySchema(schemaCopy),
[schemaCopy],
)
const {
register,
@@ -20,7 +37,7 @@ export default function NewCategoryForm() {
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateCategoryFormType>({
resolver: zodResolver(createCategorySchema),
resolver: zodResolver(schema),
})
const onSubmit = async (formData: CreateCategoryFormType) => {
@@ -49,12 +66,12 @@ export default function NewCategoryForm() {
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg">
Name
{formCopy.nameLabel}
</label>
<input
type="text"
id="name"
placeholder="Category name"
placeholder={formCopy.namePlaceholder}
{...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : ""
@@ -63,10 +80,11 @@ export default function NewCategoryForm() {
{errors.name && <p className="text-error">{errors.name.message}</p>}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Category
{formCopy.createSubmit}
</SubmitButton>
</form>
)
@@ -1,12 +1,21 @@
import { getI18n } from "@/i18n/server"
import NewCategoryForm from "../_components/new.category.form"
export default function NewCategoryPage() {
export default async function NewCategoryPage() {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Category</h1>
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<NewCategoryForm />
<NewCategoryForm
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import DeleteCategoryButton from "./_components/delete.category.button"
@@ -23,18 +24,23 @@ export default async function Items(props: {
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Categories"
title={copy.list.title}
addLabel={copy.list.addLabel}
link="/inventory/categories/new"
data={categories}
searchable={true}
search={search}
/>
{categories.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
No Categories found.
{copy.list.empty}
</div>
</div>
)}
@@ -44,13 +50,13 @@ export default async function Items(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Name
{copy.list.columns.name}
</th>
<th scope="col" className="p-4">
Items
{copy.list.columns.items}
</th>
<th scope="col" className="p-4">
Actions
{copy.list.columns.actions}
</th>
</tr>
</thead>
@@ -68,12 +74,16 @@ export default async function Items(props: {
className="btn btn-primary"
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
{category._count.items === 0 && (
<DeleteCategoryButton categoryId={category.id} />
<DeleteCategoryButton
categoryId={category.id}
copy={copy.delete}
/>
)}
</td>
</tr>
@@ -1,3 +1,4 @@
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import { ItemService } from "@/services/item.service"
@@ -11,22 +12,30 @@ export default async function AddItem({
const { itemId } = await params
const categories = await CategoryService.findAll()
const item = await ItemService.findByIdWithAssetCount(itemId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
if (!item) {
return <div>Item not found</div>
return <div>{copy.edit.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
{item?._count?.assets && item?._count.assets > 0 && (
<div className="rounded-sm bg-red-100 p-4 text-red-800">
<p>{`This item has already assets assigned to it.`}</p>
<p>{copy.edit.hasAssetsWarning}</p>
</div>
)}
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Item</h1>
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div>
<UpdateItemForm categories={categories} item={item} />
<UpdateItemForm
categories={categories}
item={item}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
@@ -1,4 +1,6 @@
import { formatMovementType } from "@/app/(dashboard)/movements/movement.copy"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
@@ -12,9 +14,12 @@ export default async function ItemPage({
const item = await ItemService.findByIdWithCategory(itemId)
const assets = await AssetService.findByItemId(itemId)
const movements = await MovementService.findAllByItemId(itemId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items.detail
const movementCopy = dictionary.inventory.movements
if (!item) {
return <div>Item not found</div>
return <div>{copy.notFound}</div>
}
return (
@@ -26,11 +31,11 @@ export default async function ItemPage({
<CardContent>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Category</span>
<span className="text-gray-600">{copy.labels.category}</span>
<span>{item.category.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Stock</span>
<span className="text-gray-600">{copy.labels.stock}</span>
<span>{item.stock}</span>
</div>
</div>
@@ -74,7 +79,7 @@ export default async function ItemPage({
{movements?.length > 0 && (
<Card className="rounded-sm shadow-none">
<CardHeader>
<CardTitle>Movements</CardTitle>
<CardTitle>{movementCopy.snippet.title}</CardTitle>
</CardHeader>
<CardContent>
{movements.map((movement) => (
@@ -83,11 +88,21 @@ export default async function ItemPage({
className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"
>
<div className="flex justify-between">
<span className="text-gray-600">Type</span>
<span>{movement.type}</span>
<span className="text-gray-600">
{movementCopy.snippet.labels.type}
</span>
<span>
{formatMovementType(
movement.type,
movementCopy.types,
movementCopy.fallback,
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Quantity</span>
<span className="text-gray-600">
{movementCopy.snippet.labels.quantity}
</span>
<span>{movement.quantity}</span>
</div>
</div>
@@ -7,7 +7,15 @@ import { toast } from "sonner"
import { deleteItemAction } from "@/actions/item.actions"
import { Button } from "@/components/ui/button"
export default function DeleteItemButton({ itemId }: { itemId: string }) {
import type { ItemDeleteCopy } from "./item.copy"
export default function DeleteItemButton({
itemId,
copy,
}: {
itemId: string
copy: ItemDeleteCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
@@ -24,7 +32,7 @@ export default function DeleteItemButton({ itemId }: { itemId: string }) {
toast.success(response.message)
router.refresh()
} else {
toast.error(response.message ?? "Unknown error")
toast.error(response.message ?? copy.unknownError)
}
})
}
@@ -38,6 +46,7 @@ export default function DeleteItemButton({ itemId }: { itemId: string }) {
size="icon"
variant="outline"
disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
>
<Trash />
</Button>
@@ -0,0 +1,8 @@
import type { Dictionary } from "@/i18n/dictionaries"
import type { ItemSchemaCopy } from "@/schemas/item.schema"
export type ItemListCopy = Dictionary["inventory"]["items"]["list"]
export type ItemDetailCopy = Dictionary["inventory"]["items"]["detail"]
export type ItemFormCopy = Dictionary["inventory"]["items"]["form"]
export type ItemDeleteCopy = Dictionary["inventory"]["items"]["delete"]
export type { ItemSchemaCopy }
@@ -1,36 +1,51 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createItemAction } from "@/actions/item.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildCreateItemResolver,
buildCreateItemSchema,
type CreateItemData,
type CreateItemFormType,
createItemSchema,
} 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({
categories,
formCopy,
schemaCopy,
submitButtonCopy,
}: {
categories: CategorySummary[]
formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(() => buildCreateItemSchema(schemaCopy), [schemaCopy])
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreateItemFormType>({
resolver: zodResolver(createItemSchema),
} = useForm<CreateItemFormType, unknown, CreateItemData>({
resolver: buildCreateItemResolver(schema),
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: CreateItemFormType) => {
const onSubmit = async (formData: CreateItemData) => {
const response = await createItemAction(formData)
if (response?.errors) {
@@ -48,7 +63,7 @@ export default function NewItemForm({
if (response?.success) {
toast.success(response.message)
router.push("/inventory/items ")
router.push("/inventory/items")
}
}
@@ -56,27 +71,29 @@ export default function NewItemForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name" className="mb-2 block text-lg">
Name
{formCopy.nameLabel}
</label>
<input
type="text"
id="name"
placeholder="Item name"
placeholder={formCopy.namePlaceholder}
{...register("name")}
className="w-full rounded-lg border px-4 py-2"
/>
{errors?.name && <p className="text-error">{errors.name.message}</p>}
{errors?.name && (
<p className="text-error">{errors.name.message as string}</p>
)}
</div>
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Category
{formCopy.categoryLabel}
</label>
<select
id="categoryId"
{...register("categoryId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a category</option>
<option value="">{formCopy.categoryPlaceholder}</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@@ -84,18 +101,18 @@ export default function NewItemForm({
))}
</select>
{errors?.categoryId && (
<p className="text-error">{errors.categoryId.message}</p>
<p className="text-error">{errors.categoryId.message as string}</p>
)}
</div>
<div>
<label htmlFor="stock" className="mb-2 block text-lg">
Stock
{formCopy.stockLabel}
</label>
<input
type="number"
id="stock"
pattern="{[0-9]*}"
placeholder="0"
placeholder={formCopy.stockPlaceholder}
min="0"
{...register("stock")}
className="w-full rounded-lg border px-4 py-2"
@@ -112,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>
<StockPolicyFields
copy={{
title: formCopy.stockPolicyTitle,
description: formCopy.stockPolicyDescription,
minStockLabel: formCopy.minStockLabel,
minStockPlaceholder: formCopy.minStockPlaceholder,
targetStockLabel: formCopy.targetStockLabel,
targetStockPlaceholder: formCopy.targetStockPlaceholder,
}}
register={register}
errors={errors}
/>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Item
{formCopy.createSubmit}
</SubmitButton>
</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,25 +1,40 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { updateItemAction } from "@/actions/item.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import {
buildUpdateItemResolver,
buildUpdateItemSchema,
type UpdateItemData,
type UpdateItemFormType,
updateItemSchema,
} 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({
categories,
item,
formCopy,
schemaCopy,
submitButtonCopy,
}: {
categories: CategorySummary[]
item: ItemWithAssetCount
formCopy: ItemFormCopy
schemaCopy: ItemSchemaCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(() => buildUpdateItemSchema(schemaCopy), [schemaCopy])
const isDisabled = !!item?._count.assets && item?._count.assets > 0
@@ -28,19 +43,21 @@ export default function UpdateItemForm({
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UpdateItemFormType>({
resolver: zodResolver(updateItemSchema),
} = useForm<UpdateItemFormType, unknown, UpdateItemData>({
resolver: buildUpdateItemResolver(schema),
defaultValues: {
id: item?.id,
name: item?.name,
categoryId: item?.category.id,
stock: item?.stock,
minStock: item?.minStock ?? undefined,
targetStock: item?.targetStock ?? undefined,
},
shouldFocusError: true,
mode: "onSubmit",
})
const onSubmit = async (formData: UpdateItemFormType) => {
const onSubmit = async (formData: UpdateItemData) => {
const response = await updateItemAction(formData)
if (response?.errors) {
@@ -58,7 +75,7 @@ export default function UpdateItemForm({
if (response?.success) {
toast.success(response.message)
router.push("/inventory/items ")
router.push("/inventory/items")
}
}
@@ -67,20 +84,22 @@ export default function UpdateItemForm({
{item?.id && <input type="hidden" name="id" value={item.id} />}
<div>
<label htmlFor="name" className="mb-2 block text-lg">
Name
{formCopy.nameLabel}
</label>
<input
type="text"
id="name"
placeholder="Item name"
placeholder={formCopy.namePlaceholder}
{...register("name")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.name && <p className="text-error">{errors.name.message}</p>}
{errors?.name && (
<p className="text-error">{errors.name.message as string}</p>
)}
</div>
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Category
{formCopy.categoryLabel}
</label>
<select
id="categoryId"
@@ -88,7 +107,7 @@ export default function UpdateItemForm({
{...register("categoryId")}
className={`w-full rounded-lg border px-4 py-2`}
>
<option value="">Select a category</option>
<option value="">{formCopy.categoryPlaceholder}</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
@@ -96,18 +115,18 @@ export default function UpdateItemForm({
))}
</select>
{errors?.categoryId && (
<p className="text-error">{errors.categoryId.message}</p>
<p className="text-error">{errors.categoryId.message as string}</p>
)}
</div>
<div>
<label htmlFor="stock" className="mb-2 block text-lg">
Stock
{formCopy.stockLabel}
</label>
<input
type="number"
id="stock"
pattern="{[0-9]*}"
placeholder="0"
placeholder={formCopy.stockPlaceholder}
min={item.stock}
disabled={isDisabled}
{...register("stock")}
@@ -127,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>
<StockPolicyFields
copy={{
title: formCopy.stockPolicyTitle,
description: formCopy.stockPolicyDescription,
minStockLabel: formCopy.minStockLabel,
minStockPlaceholder: formCopy.minStockPlaceholder,
targetStockLabel: formCopy.targetStockLabel,
targetStockPlaceholder: formCopy.targetStockPlaceholder,
}}
register={register}
errors={errors}
/>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Item
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
@@ -1,16 +1,24 @@
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service"
import NewItemForm from "../_components/new.item.form"
export default async function NewItemPage() {
const categories = await CategoryService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Item</h1>
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<NewItemForm categories={categories} />
<NewItemForm
categories={categories}
formCopy={copy.form}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
)
}
+43 -11
View File
@@ -4,10 +4,24 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { ItemService } from "@/services/item.service"
import DeleteItemButton from "./_components/delete.item.button"
function formatStockPolicy(
item: { minStock: number | null; targetStock: number | null },
copy: { configured: string; none: string },
) {
if (item.minStock === null || item.targetStock === null) {
return copy.none
}
return copy.configured
.replace("{min}", String(item.minStock))
.replace("{target}", String(item.targetStock))
}
export default async function ItemsPage(props: {
searchParams?: Promise<{
page?: string
@@ -22,19 +36,23 @@ export default async function ItemsPage(props: {
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.items
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Items"
title={copy.list.title}
link="/inventory/items/new"
addLabel={copy.list.addLabel}
data={items}
searchable={true}
search={search}
/>
{items.length === 0 && currentPage === 1 && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
No items found.
{copy.list.empty}
</div>
</div>
)}
@@ -44,19 +62,22 @@ export default async function ItemsPage(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Name
{copy.list.columns.name}
</th>
<th scope="col" className="p-4">
Category
{copy.list.columns.category}
</th>
<th scope="col" className="p-4">
Assets
{copy.list.columns.assets}
</th>
<th scope="col" className="p-4">
Stock
{copy.list.columns.stock}
</th>
<th scope="col" className="p-4">
Actions
{copy.list.columns.stockPolicy}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th>
</tr>
</thead>
@@ -67,19 +88,30 @@ export default async function ItemsPage(props: {
<td className="p-4">{item.category.name}</td>
<td className="p-4">{item._count.assets}</td>
<td className="p-4">{item.stock}</td>
<td className="p-4">
{formatStockPolicy(item, copy.list.stockPolicy)}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/items/${item.id}`} passHref>
<Button variant="outline" size="icon">
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.view}
>
<Eye />
</Button>
</Link>
<Link href={`/inventory/items/${item.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
{item._count.assets === 0 && item.stock === 0 && (
<DeleteItemButton itemId={item.id} />
<DeleteItemButton itemId={item.id} copy={copy.delete} />
)}
</td>
</tr>
@@ -87,7 +119,7 @@ export default async function ItemsPage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
+3 -3
View File
@@ -3,18 +3,18 @@ import { Toaster } from "sonner"
import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { auth } from "@/lib/auth"
import { getI18n } from "@/i18n/server"
export default async function LayoutDashboard({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
const { dictionary } = await getI18n()
return (
<SidebarProvider>
<AppSidebar userRole={session?.user.role} />
<AppSidebar copy={dictionary.layout.sidebar} />
<main className="w-full">
<Navbar />
<div className="flex-1 p-6">{children}</div>
@@ -0,0 +1,15 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type MovementTypeCopy = Dictionary["inventory"]["movements"]["types"]
export type MovementFallbackCopy =
Dictionary["inventory"]["movements"]["fallback"]
export function formatMovementType(
type: string,
typeCopy: MovementTypeCopy,
fallbackCopy: MovementFallbackCopy,
) {
return type in typeCopy
? typeCopy[type as keyof MovementTypeCopy]
: fallbackCopy.unknownType
}
+28 -13
View File
@@ -1,7 +1,10 @@
import PaginationButtons from "@/components/common/pagination"
import { getI18n } from "@/i18n/server"
import { formatDate } from "@/lib/utils"
import { MovementService } from "@/services/movement.service"
import { formatMovementType } from "./movement.copy"
export default async function MovementsPage(props: {
searchParams?: Promise<{
page?: string
@@ -13,50 +16,62 @@ export default async function MovementsPage(props: {
page: currentPage,
pageSize: 12,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.movements
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Movements</h1>
<h1 className="text-2xl font-bold">{copy.list.title}</h1>
</div>
{movements.length === 0 && <div>No movements found</div>}
{movements.length === 0 && <div>{copy.list.empty}</div>}
{movements.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Type
{copy.list.columns.type}
</th>
<th scope="col" className="p-4">
Item
{copy.list.columns.item}
</th>
<th scope="col" className="p-4">
Serial Number
{copy.list.columns.serialNumber}
</th>
<th scope="col" className="p-4">
Quantity
{copy.list.columns.quantity}
</th>
<th scope="col" className="p-4">
Recipient
{copy.list.columns.person}
</th>
<th scope="col" className="p-4">
Date
{copy.list.columns.date}
</th>
</tr>
</thead>
<tbody>
{movements.map((movement) => (
<tr key={movement.id} className="border-b">
<td className="p-4">{movement.type}</td>
<td className="p-4">{movement?.item?.name}</td>
<td className="p-4">
{movement?.asset?.serialNumber || "-"}
{formatMovementType(
movement.type,
copy.types,
copy.fallback,
)}
</td>
<td className="p-4">
{movement?.item?.name || copy.fallback.missingValue}
</td>
<td className="p-4">
{movement?.asset?.serialNumber ||
copy.fallback.missingValue}
</td>
<td className="p-4">{movement.quantity}</td>
<td className="p-4">
{movement?.recipient?.firstName || "-"}{" "}
{movement?.recipient?.lastName || "-"}
{movement?.person
? `${movement.person.firstName} ${movement.person.lastName}`
: copy.fallback.missingValue}
</td>
<td className="p-4">{formatDate(movement.createdAt)}</td>
</tr>
@@ -0,0 +1,38 @@
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import { 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>
)
}
@@ -2,7 +2,7 @@ import type { ReactNode } from "react"
import { requireRole } from "@/services/auth.service"
export default async function AdminLayout({
export default async function PeopleLayout({
children,
}: {
children: ReactNode
+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,176 +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 {
createNewRecipient,
updateRecipient,
} from "@/actions/recipient.actions"
import { SubmitButton } from "@/components/forms/submitButton"
import { RECIPIENT_DEPARTMENTS } from "@/lib/constants"
import {
type CreateRecipientFormType,
recipientSchema,
type UpdateRecipientFormType,
} from "@/schemas/recipient.schema"
import type { Recipient } from "@/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(RECIPIENT_DEPARTMENTS).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>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { Eye, Pencil } from "lucide-react"
import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import type { Recipient } from "@/generated/prisma/client"
import { RecipientService } from "@/services/recipient.service"
export default async function RecipientsPage(props: {
searchParams?: Promise<{
page?: string
search?: string
}>
}) {
const searchParams = await props.searchParams
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
const search = searchParams?.search || ""
const { data: recipients, totalPages } =
await RecipientService.findAllPaginated({
page: currentPage,
pageSize: 10,
search,
})
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Recipients"
link="/recipients/new"
data={recipients}
search={search}
/>
{recipients.length === 0 && <div>No recipients found</div>}
{recipients.length > 0 && (
<div className="overflow-x-auto">
<table className="text-muted-foreground w-full text-left text-sm">
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Username
</th>
<th scope="col" className="p-4">
Name
</th>
<th scope="col" className="p-4">
Email
</th>
<th scope="col" className="p-4">
Phone
</th>
<th scope="col" className="p-4">
Department
</th>
<th scope="col" className="p-4">
Actions
</th>
</tr>
</thead>
<tbody>
{recipients.map((recipient: Recipient) => (
<tr key={recipient.id} className="border-b">
<td className="p-4">{recipient.username}</td>
<td className="p-4">
{`${recipient.firstName} ${recipient.lastName}`}
</td>
<td className="p-4">{recipient.email}</td>
<td className="p-4">{recipient.phone}</td>
<td className="p-4">{recipient.department}</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/recipients/${recipient.id}`} passHref>
<Button variant="outline" size="icon">
<Eye />
</Button>
</Link>
<Link href={`/recipients/${recipient.id}/edit`} passHref>
<Button
className="btn btn-primary"
variant="outline"
size="icon"
>
<Pencil />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={6} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
+8 -4
View File
@@ -1,11 +1,15 @@
import Link from "next/link"
export default function ForbiddenPage() {
import { getI18n } from "@/i18n/server"
export default async function ForbiddenPage() {
const { dictionary } = await getI18n()
const copy = dictionary.common.forbidden
return (
<main>
<h1>Acceso denegado</h1>
<p>No tienes permisos para acceder a esta sección.</p>
<Link href="/">Volver al inicio</Link>
<h1>{copy.title}</h1>
<p>{copy.description}</p>
<Link href="/">{copy.homeLink}</Link>
</main>
)
}

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