Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7abf298da1 | |||
| 79bd1b5d5e | |||
| d1def3353a | |||
| 66c703e484 | |||
| 925bafff1a | |||
| 1e9c5b9627 | |||
| 723c41f0c9 | |||
| 15494b2539 | |||
| efda051aa3 | |||
| b401f254ec | |||
| 2c03cd4d66 | |||
| de40e0bf73 | |||
| e4bd76a353 | |||
| 9b023ee558 | |||
| 44565e3a62 | |||
| 18e274ef37 | |||
| b4b63e107a | |||
| 91dc0220ae | |||
| 1142855578 | |||
| a0a1e1bdc8 | |||
| 8f7a406e83 | |||
| 95c52579d1 | |||
| 575cd2d9a0 | |||
| 0d38626f3a | |||
| d217edd4e5 | |||
| f32d55a7b0 | |||
| c1763ed007 | |||
| 965a04a468 | |||
| 7b8a415c6a | |||
| c123170a46 | |||
| 6d34a2f74f | |||
| 8e6a00c2a9 | |||
| 01d89cd21b | |||
| 2ed9445f7f | |||
| 1a95bf4613 | |||
| fff5ce01e5 | |||
| fb5f3da7c1 | |||
| df02d24d79 | |||
| 92f83c708a | |||
| d6b42d78e7 | |||
| 4f370eee70 | |||
| 1f5a849bf5 | |||
| e5717461cf | |||
| 68c2983d36 | |||
| caf19575c6 | |||
| cf6820a7aa | |||
| 29c7c19cd8 | |||
| ecc3cf1b55 | |||
| d67f31cf54 | |||
| befe1f3f82 | |||
| 20552ba68b | |||
| 73552dbb05 | |||
| 0cbbe60299 | |||
| 349559f4e0 | |||
| bfea2b77ab | |||
| 9b713c42e2 | |||
| c0ae7a034a | |||
| ea37fc8d70 | |||
| f62cd6fb37 | |||
| 7d5ab64653 | |||
| 3d6b13dc1c | |||
| c67e86c91b | |||
| 964b1648ca | |||
| 9f7d1b8ef8 | |||
| e9a07eb28e | |||
| 589b042e7c | |||
| 2fa6611719 | |||
| c3cf4182ad | |||
| 18a192a069 | |||
| ac3dfe69cd | |||
| 2c6d6bffcd | |||
| f2b9239d82 | |||
| cb01f4f8ef | |||
| bc5926a5de | |||
| 16a68e01ab |
@@ -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.
|
||||
@@ -38,4 +35,4 @@
|
||||
"containerEnv": {
|
||||
"SHELL": "/bin/bash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
@@ -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/
|
||||
Vendored
+5
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **W-3 (fix-assignment-asset-movement-audit)**: `Assignment.id` is no longer stable across person swaps. When `updateAssignmentUseCase` or `updateAssetUseCase` changes a person on an active assignment, the old assignment is now closed (with `closedAt`/`closedById` set; `AssignmentStockReturn` row created for QUANTITY; `AssignmentAssetLine.returnedAt` set for SERIALIZED) and a NEW `Assignment` is created with a new `id`. Any code that holds an `Assignment.id` expecting stability across person swaps must be updated to look up the new id (e.g., via the active `Assignment` for the person/asset). The change replaces a phantom `RETURN` + `ASSIGNMENT` pattern that wrote both movements to the same `Assignment.id` without creating an `AssignmentStockReturn` row.
|
||||
|
||||
## Released
|
||||
|
||||
_Unreleased changes appear above. Released versions will be added below as they are tagged._
|
||||
@@ -1,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.
|
||||
|
||||
+1
-1
@@ -63,4 +63,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-1
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
+16
-2
@@ -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",
|
||||
@@ -69,4 +83,4 @@
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = process.env.E2E_PORT ?? "3100"
|
||||
const baseURL = `http://127.0.0.1:${port}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testMatch: "**/*.spec.ts",
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "bun tests/e2e/support/e2e-server.ts",
|
||||
url: baseURL,
|
||||
timeout: 180_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
+96
-27
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
type CategoryActionCopy = Dictionary["inventory"]["categories"]["actions"]
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
const categoryErrorMessageKeys = {
|
||||
"Category already exists": "duplicateName",
|
||||
"Category name is the same as the old one": "unchangedName",
|
||||
"Category name unchanged": "unchangedName",
|
||||
"Category not found": "notFound",
|
||||
"Category has items": "hasItems",
|
||||
"Cannot delete category with items": "hasItems",
|
||||
} as const satisfies Record<string, keyof CategoryActionCopy>
|
||||
|
||||
function isCategoryErrorMessage(
|
||||
message: string,
|
||||
): message is keyof typeof categoryErrorMessageKeys {
|
||||
return message in categoryErrorMessageKeys
|
||||
}
|
||||
|
||||
function localizeCategoryMessage(
|
||||
message: string,
|
||||
copy: CategoryActionCopy,
|
||||
): string {
|
||||
if (!isCategoryErrorMessage(message)) return message
|
||||
|
||||
return copy[categoryErrorMessageKeys[message]]
|
||||
}
|
||||
|
||||
export function localizeCategoryFieldErrors(
|
||||
errors: FieldErrors | undefined,
|
||||
copy: CategoryActionCopy,
|
||||
): FieldErrors | undefined {
|
||||
if (!errors) return undefined
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(errors).map(([field, messages]) => [
|
||||
field,
|
||||
messages.map((message) => localizeCategoryMessage(message, copy)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use server"
|
||||
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
import {
|
||||
isLocale,
|
||||
LOCALE_COOKIE_MAX_AGE_SECONDS,
|
||||
LOCALE_COOKIE_NAME,
|
||||
type Locale,
|
||||
} from "@/i18n/locales"
|
||||
|
||||
export type SetLocaleActionResult =
|
||||
| { success: true; locale: Locale }
|
||||
| { success: false; error: "UNSUPPORTED_LOCALE" }
|
||||
|
||||
export async function setLocaleAction(
|
||||
requestedLocale: string,
|
||||
): Promise<SetLocaleActionResult> {
|
||||
if (!isLocale(requestedLocale)) {
|
||||
return { success: false, error: "UNSUPPORTED_LOCALE" }
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(LOCALE_COOKIE_NAME, requestedLocale, {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
maxAge: LOCALE_COOKIE_MAX_AGE_SECONDS,
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
})
|
||||
|
||||
return { success: true, locale: requestedLocale }
|
||||
}
|
||||
@@ -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
@@ -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: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
type ItemActionCopy = Dictionary["inventory"]["items"]["actions"]
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
const itemErrorMessageKeys = {
|
||||
"An item with this name already exists": "duplicateName",
|
||||
"Item not found": "notFound",
|
||||
"Item has assets, you cannot delete it": "hasAssets",
|
||||
"Item has stock, you cannot delete it": "hasStock",
|
||||
"Invalid stock": "invalidStock",
|
||||
"Stock cannot be negative": "negativeStock",
|
||||
} as const satisfies Record<string, keyof ItemActionCopy>
|
||||
|
||||
function isItemErrorMessage(
|
||||
message: string,
|
||||
): message is keyof typeof itemErrorMessageKeys {
|
||||
return message in itemErrorMessageKeys
|
||||
}
|
||||
|
||||
function localizeItemMessage(message: string, copy: ItemActionCopy): string {
|
||||
if (!isItemErrorMessage(message)) return message
|
||||
|
||||
return copy[itemErrorMessageKeys[message]]
|
||||
}
|
||||
|
||||
export function localizeItemFieldErrors(
|
||||
errors: FieldErrors | undefined,
|
||||
copy: ItemActionCopy,
|
||||
): FieldErrors | undefined {
|
||||
if (!errors) return undefined
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(errors).map(([field, messages]) => [
|
||||
field,
|
||||
messages.map((message) => localizeItemMessage(message, copy)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { flattenError } from "zod"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import {
|
||||
buildCreatePersonSchema,
|
||||
buildUpdatePersonSchema,
|
||||
type CreatePersonFormType,
|
||||
type UpdatePersonFormType,
|
||||
} from "@/schemas/person.schema"
|
||||
import {
|
||||
buildUnifiedCreateSchema,
|
||||
buildUnifiedUpdateSchema,
|
||||
type UnifiedCreateFormType,
|
||||
type UnifiedSchemaCopy,
|
||||
type UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import {
|
||||
createPersonUseCase,
|
||||
createPersonUserUseCase,
|
||||
updatePersonUseCase,
|
||||
updatePersonUserUseCase,
|
||||
} from "@/use-cases/person.use-cases"
|
||||
|
||||
import { localizePersonFieldErrors } from "./person.messages"
|
||||
import { localizeUnifiedCreateFieldErrors } from "./user.messages"
|
||||
|
||||
const PERSON_USER_PATH = "/people"
|
||||
|
||||
export async function createNewPerson(formData: CreatePersonFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const validatedFields = buildCreatePersonSchema(copy.schema).safeParse(
|
||||
formData,
|
||||
)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPersonUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizePersonFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.createFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: copy.actions.createSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: copy.actions.createFailure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPersonUserAction(formData: UnifiedCreateFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
const userCopy = dictionary.admin.users
|
||||
const schemaCopy = {
|
||||
...userCopy.schema,
|
||||
...dictionary.inventory.people.schema,
|
||||
}
|
||||
const validatedFields =
|
||||
buildUnifiedCreateSchema(schemaCopy).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPersonUserUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeUnifiedCreateFieldErrors(
|
||||
result.errors,
|
||||
userCopy.actions,
|
||||
schemaCopy,
|
||||
),
|
||||
message: userCopy.actions.createFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(PERSON_USER_PATH)
|
||||
|
||||
return { success: true, message: userCopy.actions.createSuccess }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: userCopy.actions.createFailure }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePerson(formData: UpdatePersonFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const validatedFields = buildUpdatePersonSchema(copy.schema).safeParse(
|
||||
formData,
|
||||
)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updatePersonUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizePersonFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: copy.actions.updateSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: copy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePersonUserAction(formData: UnifiedUpdateFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
const userCopy = dictionary.admin.users
|
||||
const personCopy = dictionary.inventory.people
|
||||
const schemaCopy: UnifiedSchemaCopy = {
|
||||
...userCopy.schema,
|
||||
...personCopy.schema,
|
||||
}
|
||||
const validatedFields =
|
||||
buildUnifiedUpdateSchema(schemaCopy).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updatePersonUserUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeUnifiedCreateFieldErrors(
|
||||
result.errors,
|
||||
userCopy.actions,
|
||||
schemaCopy,
|
||||
),
|
||||
message: personCopy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return { success: true, message: personCopy.actions.updateSuccess }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: personCopy.actions.updateFailure }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { flattenError } from "zod"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import {
|
||||
buildCreateTeamSchema,
|
||||
buildUpdateTeamSchema,
|
||||
type CreateTeamFormType,
|
||||
type UpdateTeamFormType,
|
||||
} from "@/schemas/team.schema"
|
||||
import { getAuthenticatedSession, requireRole } from "@/services/auth.service"
|
||||
import {
|
||||
createTeamUseCase,
|
||||
deleteTeamUseCase,
|
||||
listTeamsUseCase,
|
||||
updateTeamUseCase,
|
||||
} from "@/use-cases/team.use-cases"
|
||||
|
||||
import { localizeTeamFieldErrors } from "./team.messages"
|
||||
|
||||
export async function createTeamAction(formData: CreateTeamFormType) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
const validatedFields = buildCreateTeamSchema(copy.schema).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createTeamUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeTeamFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.createFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: copy.actions.createSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: copy.actions.createFailure,
|
||||
errors: {
|
||||
name: [copy.actions.duplicateName],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeamAction(formData: UpdateTeamFormType) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
const validatedFields = buildUpdateTeamSchema(copy.schema).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateTeamUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeTeamFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: copy.actions.updateSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false,
|
||||
message: copy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeamAction(formData: FormData) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.teams
|
||||
const { id } = Object.fromEntries(formData) as { id: string }
|
||||
|
||||
try {
|
||||
const result = await deleteTeamUseCase(id)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeTeamFieldErrors(result.errors, copy.actions),
|
||||
message: copy.actions.deleteFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
message: copy.actions.deleteSuccess,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return {
|
||||
success: false as const,
|
||||
message: copy.actions.deleteFailure,
|
||||
errors: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTeamsAction() {
|
||||
await getAuthenticatedSession()
|
||||
|
||||
return listTeamsUseCase()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
type TeamActionCopy = Dictionary["inventory"]["teams"]["actions"]
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
const teamErrorMessageKeys = {
|
||||
"Team already exists": "duplicateName",
|
||||
"Team name is the same": "unchangedName",
|
||||
"Team name unchanged": "unchangedName",
|
||||
"Team not found": "notFound",
|
||||
} as const satisfies Record<string, keyof TeamActionCopy>
|
||||
|
||||
function isTeamErrorMessage(
|
||||
message: string,
|
||||
): message is keyof typeof teamErrorMessageKeys {
|
||||
return message in teamErrorMessageKeys
|
||||
}
|
||||
|
||||
function localizeTeamMessage(message: string, copy: TeamActionCopy): string {
|
||||
if (!isTeamErrorMessage(message)) return message
|
||||
|
||||
return copy[teamErrorMessageKeys[message]]
|
||||
}
|
||||
|
||||
export function localizeTeamFieldErrors(
|
||||
errors: FieldErrors | undefined,
|
||||
copy: TeamActionCopy,
|
||||
): FieldErrors | undefined {
|
||||
if (!errors) return undefined
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(errors).map(([field, messages]) => [
|
||||
field,
|
||||
messages.map((message) => localizeTeamMessage(message, copy)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
+54
-30
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { LanguageSwitcher } from "@/components/i18n/language-switcher"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
import SignInForm from "./_components/login-form"
|
||||
@@ -10,15 +12,26 @@ export default async function LoginPage() {
|
||||
|
||||
if (session) redirect("/")
|
||||
|
||||
const { dictionary, locale } = await getI18n()
|
||||
const copy = dictionary.login
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<LanguageSwitcher
|
||||
activeLocale={locale}
|
||||
copy={dictionary.common.languageSwitcher}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardTitle>
|
||||
<h1>{copy.title}</h1>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignInForm />
|
||||
<SignInForm copy={copy} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ import Link from "next/link"
|
||||
export default function Card({
|
||||
title,
|
||||
total,
|
||||
countLabel,
|
||||
icon,
|
||||
href,
|
||||
}: {
|
||||
title: string
|
||||
total: number
|
||||
countLabel: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}) {
|
||||
@@ -18,7 +20,9 @@ export default function Card({
|
||||
<div className="mr-4">{icon}</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Total: {total}</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{countLabel}: {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import Card from "./_components/card"
|
||||
|
||||
export default async function Home() {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.dashboardHome
|
||||
const totalItems = await ItemService.findAllItemsCount()
|
||||
const totalAssets = await AssetService.findAllAssetsCount()
|
||||
const totalRecipients = await RecipientService.findAllRecipientsCount()
|
||||
const totalPeople = await PersonService.findAllPeopleCount()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="mb-4 text-2xl font-bold">Dashboard</h1>
|
||||
<h1 className="mb-4 text-2xl font-bold">{copy.heading}</h1>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card
|
||||
title="Total Items"
|
||||
title={copy.cards.items.title}
|
||||
total={totalItems}
|
||||
countLabel={copy.cards.items.countLabel}
|
||||
href="/inventory/items"
|
||||
icon={
|
||||
<svg
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssetService } from "@/services/asset.service"
|
||||
import { ItemService } from "@/services/item.service"
|
||||
import { RecipientService } from "@/services/recipient.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import AssignmentForm from "../_components/new.assignment.form"
|
||||
|
||||
export default async function NewAssignmentPage() {
|
||||
const recipients = await RecipientService.findAll()
|
||||
const people = await PersonService.findAll()
|
||||
const items = await ItemService.findAllWithStock()
|
||||
const assets = await AssetService.findAllAvailable()
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.assignments
|
||||
|
||||
return (
|
||||
<AssignmentForm recipients={recipients} items={items} assets={assets} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
|
||||
</div>
|
||||
<AssignmentForm
|
||||
people={people}
|
||||
items={items}
|
||||
assets={assets}
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link"
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { AssignmentService } from "@/services/assignment.service"
|
||||
|
||||
import ReturnButton from "./_components/return.button"
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user