commit 2dc41ed4fa61fcb0f2b5da200a68984958e565d1 Author: Nurfog Date: Tue Oct 13 17:32:55 2026 -0300 feat: modulo de proyectos frontend (Next 16 + Bootstrap 5) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1459104 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Configuración local de desarrollo (no se commitea: ver .gitignore). +# +# SAM_API_URL y SAM_API_KEY NO llevan prefijo NEXT_PUBLIC_: solo las ve el +# servidor (el route handler src/app/api/proyectos/[...path]/route.ts), así +# que la API key no se empaqueta en el JavaScript del navegador. + +# URL base de SAM.API (el backend compartido de SAM). +SAM_API_URL=http://127.0.0.1:5000 + +# API key global de SAM, enviada en el header X-Api-Key por el proxy. +# Defínela como variable de entorno real en producción, no en este archivo. +SAM_API_KEY= + +# Muestra la UI con datos mock en memoria, sin backend. +# Descomenta solo para desarrollar sin SAM.API levantado: +# NEXT_PUBLIC_USE_MOCK=true + +# Identificador del módulo (informativo, lo usa el deployer de SAM). +NEXT_PUBLIC_MODULE=proyectos diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b634ead --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# dependencies +/.pnp +.pnp.* +node_modules + +# next.js +/.next/ +/out/ +next-env.d.ts + +# production +build/ +dist/ + +# misc +.DS_Store +*.pem +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env +.env +.env*.local + +# typescript +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee20047 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine AS base + +# Deps +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm install + +# Build +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# Production +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9de2229 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Proyectos · SAM + +Frontend del módulo de gestión de proyectos de la plataforma **SAM**, con +estética y flujo de trabajo al estilo [Linear](https://linear.app). + +Es una aplicación **Next.js 16 + Bootstrap 5** (App Router, TypeScript). No +tiene backend propio: se apoya en `SAM.API`, la API compartida de SAM. + +## Características + +- **Issues**: lista agrupada por estado, con filtros y edición en línea. +- **Detalle**: descripción, propiedades editables y comentarios. +- **Tablero Kanban**: arrastrar y soltar entre columnas de estado. +- **Ciclos**: sprints con progreso y issues asignados. +- **Proyectos**: agrupan issues, con lead, fechas y barra de progreso. +- **Bandeja**: issues abiertos asignados al usuario actual. +- **Palette de comandos**: `Cmd/Ctrl + K` para navegar y crear issues. + +## Requisitos + +- Node.js 22+ +- npm + +## Desarrollo + +```bash +npm install +npm run dev +``` + +La app queda en `http://localhost:3000`. + +### Scripts + +| Comando | Descripción | +| ------------------- | ---------------------------------- | +| `npm run dev` | Servidor de desarrollo | +| `npm run build` | Build de producción (standalone) | +| `npm run start` | Sirve el build de producción | +| `npm run typecheck` | Comprueba tipos sin emitir | + +## Arquitectura + +``` +src/ +├── app/ # Rutas (App Router) +│ ├── inbox/ # Issues asignados al usuario +│ ├── issues/ # Lista de issues +│ │ └── [id]/ # Detalle de un issue +│ ├── board/ # Tablero Kanban +│ ├── cycles/ # Ciclos / sprints +│ └── projects/ # Proyectos +│ └── [id]/ # Detalle de proyecto +├── components/ +│ ├── layout/ # Sidebar, Topbar, AppShell, palette +│ ├── issues/ # IssueList, IssueRow +│ ├── board/ # Tablero Kanban +│ └── ui/ # Avatar, iconos, etiquetas, progreso +├── hooks/ +│ └── useIssues.ts # Carga de issues con filtros y refresco +└── lib/ + ├── types.ts # Modelo de dominio (contrato con SAM) + ├── meta.ts # Definiciones de estado/prioridad/tipo + ├── format.ts # Helpers de formato de fechas + ├── seed.ts # Datos de ejemplo (mock) + ├── store.tsx # Contexto de datos de referencia + └── api/ + ├── client.ts # Interfaz ProyectosApi + ├── mock.ts # Implementación en memoria + └── index.ts # Selección de implementación +``` + +### Capa de datos + +Toda la app habla contra la interfaz `ProyectosApi` (`src/lib/api/client.ts`), +nunca contra una implementación concreta. Hay dos implementaciones: + +- `httpApi` (`src/lib/api/http.ts`): la real, contra `SAM.API`. Es la que se + usa por defecto. +- `mockApi` (`src/lib/api/mock.ts`): datos en memoria sobre `src/lib/seed.ts`, + para desarrollar la UI sin backend. Se activa con `NEXT_PUBLIC_USE_MOCK=true`. + +La selección está en `src/lib/api/index.ts`. Para cambiar de implementación +no hay que tocar componentes, hooks ni páginas: el contrato no cambia. + +### Autenticación + +La API key global de SAM (`X-Api-Key`) **nunca llega al navegador**. + +El navegador llama a `/api/proyectos/*` en el mismo origen. Ese route handler +(`src/app/api/proyectos/[...path]/route.ts`) reenvía la petición a `SAM.API` +añadiendo la cabecera en el servidor. + +La clave vive en `SAM_API_KEY`, sin prefijo `NEXT_PUBLIC_`. Next.js solo +inyecta en el bundle JS las variables `NEXT_PUBLIC_*`, así que esta queda +restringida al proceso del servidor. + +### Sesión de usuario + +`AuthProvider` (`src/lib/auth.tsx`) guarda en localStorage el miembro devuelto +por `POST /api/proyectos/auth/login`, que valida contra `sige_sam_v3.usuario`. +Ese `idUsuario` es quien firma los comentarios y filtra la bandeja. + +> **Nota**: los tipos de `src/lib/types.ts` son el contrato esperado del +> backend. Los endpoints de `SAM.API` devuelven esta misma forma. + +## Despliegue en SAM + +Sigue el patrón de módulo de SAM: frontend independiente conectado a la API +compartida a través de la red `sam-net`. + +- `Dockerfile`: build standalone de Next.js. +- `docker-compose.module.yml`: servicio `proyectos-frontend` con healthcheck. + +Dentro de la red `sam-net` el frontend alcanza el backend por nombre de +contenedor: `SAM_API_URL=http://sam-api:5000`. + +`SAM_API_KEY` se inyecta desde el entorno del host (`${SAM_API_KEY}` en el +compose); no se escribe en el archivo. + +## Variables de entorno + +| Variable | Quién la lee | Descripción | +| ---------------------- | ------------ | ------------------------------------------------------ | +| `SAM_API_URL` | servidor | URL base de `SAM.API`. Por defecto `http://127.0.0.1:5000`. | +| `SAM_API_KEY` | servidor | API key global de SAM (`X-Api-Key`). Sin ella el proxy responde 503. | +| `NEXT_PUBLIC_MODULE` | cliente | Identificador del módulo (informativo). | +| `NEXT_PUBLIC_USE_MOCK` | cliente | `true` para usar datos mock en memoria sin backend. | + +> `SAM_API_URL` y `SAM_API_KEY` no llevan prefijo `NEXT_PUBLIC_` a propósito: +> solo las ve el proceso del servidor, así que la clave no se empaqueta en el +> JavaScript que descarga el navegador. diff --git a/docker-compose.module.yml b/docker-compose.module.yml new file mode 100644 index 0000000..abe55ad --- /dev/null +++ b/docker-compose.module.yml @@ -0,0 +1,32 @@ +# ============================================================ +# Módulo: proyectos (frontend) +# Solo Frontend Next.js (backend compartido SAM.API) +# ============================================================ + +services: + proyectos-frontend: + build: + context: . + dockerfile: Dockerfile + container_name: proyectos-frontend + restart: unless-stopped + environment: + # La URL de SAM.API y su API key NO llevan prefijo NEXT_PUBLIC_: + # las lee solo el servidor (route handler src/app/api/proyectos/[...path]/route.ts). + # Así la clave no se empaqueta en el bundle JS del navegador. + # Dentro de sam-net el backend es alcanzable por nombre de contenedor. + SAM_API_URL: http://sam-api:5000 + # Se inyecta desde el entorno del host; nunca se escribe aquí. + SAM_API_KEY: ${SAM_API_KEY} + NEXT_PUBLIC_MODULE: proyectos + networks: + - sam-net + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 5s + retries: 3 + +networks: + sam-net: + external: true diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..3df43b8 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e880897 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1382 @@ +{ + "name": "sam-proyectos", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sam-proyectos", + "version": "1.0.0", + "dependencies": { + "bootstrap": "^5.3.8", + "boxicons": "^2.1.4", + "next": "^16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@webcomponents/webcomponentsjs": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz", + "integrity": "sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==", + "license": "BSD-3-Clause" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/boxicons": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/boxicons/-/boxicons-2.1.4.tgz", + "integrity": "sha512-BvJNfYfnE4g9WQ7GL91fftxMOTwAleWlPFwvQJPYb/Ju7aLjlQ/Eu55AH9JLNk/OR82z+ZSq4TbKzbV/e5Rr0A==", + "license": "(CC-BY-4.0 OR OFL-1.1 OR MIT)", + "dependencies": { + "@webcomponents/webcomponentsjs": "^2.0.2", + "prop-types": "^15.6.0", + "react": "^16.0.0", + "react-dom": "^16.0.0", + "react-interactive": "^0.8.1", + "react-router-dom": "^4.2.2" + } + }, + "node_modules/boxicons/node_modules/react": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", + "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/boxicons/node_modules/react-dom": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.14.0" + } + }, + "node_modules/boxicons/node_modules/scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-hover": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-hover/-/detect-hover-1.0.3.tgz", + "integrity": "sha512-HtLoY+tClgYucJNiovNICGWFp9nOGVmHY44s7L62iPqORXM9vujeWFaVcqtA7XRvp/2Y+4RBUfHbDKFGN+xxZQ==", + "license": "MIT" + }, + "node_modules/detect-it": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/detect-it/-/detect-it-3.0.7.tgz", + "integrity": "sha512-RxpgcdbatUX6epJE09K16iJqF7x6iEcEdoL18FR2zpBO4JhnL7aMOAUoUEyexdtbWOSfTmoDWmeD6mwRBQyRXg==", + "license": "MIT", + "dependencies": { + "detect-hover": "^1.0.3", + "detect-passive-events": "^1.0.5", + "detect-pointer": "^1.0.3", + "detect-touch-events": "^2.0.2" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-passive-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/detect-passive-events/-/detect-passive-events-1.0.5.tgz", + "integrity": "sha512-foW7Q35wwOCxVzW0xLf5XeB5Fhe7oyRgvkBYdiP9IWgLMzjqUqTvsJv9ymuEWGjY6AoDXD3OC294+Z9iuOw0QA==", + "license": "MIT" + }, + "node_modules/detect-pointer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-pointer/-/detect-pointer-1.0.3.tgz", + "integrity": "sha512-d0o/Puo3fiGSCXy6H039h9Kwz+mmYCGKZ/qtPFnpN3WfsumjC1C9b5KKvRu+aYnfdI8peqN/iAe7dPd85qIt2g==", + "license": "MIT" + }, + "node_modules/detect-touch-events": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-touch-events/-/detect-touch-events-2.0.2.tgz", + "integrity": "sha512-g8GWBkJLiIDRJfRXEdrd1wMXpNyGId2DkbfuwFahSb4OCvn717hyRJtAcEDISfp3zkwEhZ4Y4woHPA6DeyB3Fw==", + "license": "MIT" + }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==", + "license": "BSD-3-Clause" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", + "license": "MIT", + "dependencies": { + "@next/env": "16.4.0", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", + "sharp": "^0.35.4" + }, + "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" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-interactive": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/react-interactive/-/react-interactive-0.8.3.tgz", + "integrity": "sha512-mmRvA9aKP7zu9kVfP1AX1egX8tFlnE3DDXq92z0JTZezfOpmeQBzr77O1+mTV54OOmn+M2t6c5kFD5VnuFoM7A==", + "license": "MIT", + "dependencies": { + "detect-it": "^3.0.3", + "object-assign": "^4.1.1", + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz", + "integrity": "sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg==", + "license": "MIT", + "dependencies": { + "history": "^4.7.2", + "hoist-non-react-statics": "^2.5.0", + "invariant": "^2.2.4", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.1", + "warning": "^4.0.1" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-dom": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz", + "integrity": "sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA==", + "license": "MIT", + "dependencies": { + "history": "^4.7.2", + "invariant": "^2.2.4", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.1", + "react-router": "^4.3.1", + "warning": "^4.0.1" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", + "license": "MIT" + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1fbe02f --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "sam-proyectos", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "bootstrap": "^5.3.8", + "boxicons": "^2.1.4", + "next": "^16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.6.0" + } +} diff --git a/src/app/api/proyectos/[...path]/route.ts b/src/app/api/proyectos/[...path]/route.ts new file mode 100644 index 0000000..c173388 --- /dev/null +++ b/src/app/api/proyectos/[...path]/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Proxy servidor <-> SAM.API para el módulo de Proyectos. + * + * Motivo de existir: la API key global de SAM (`X-Api-Key`) no debe llegar + * jamás al navegador. Si el frontend la enviara directamente quedaría embebida + * en el bundle JS (NEXT_PUBLIC_* se inyecta en build time) y cualquier + * visitante podría leerla con las DevTools. + * + * Aquí la clave se lee de `SAM_API_KEY`, que NO lleva prefijo NEXT_PUBLIC_: + * Next.js solo expone al cliente las variables NEXT_PUBLIC_*, así que esta + * vive únicamente en el proceso del servidor (Node, no Edge). + * + * El navegador habla contra `/api/proyectos/*` en el mismo origen y este + * route handler reenvía la petición a SAM.API añadiendo la cabecera. + */ + +// Node runtime: la variable de servidor solo está disponible aquí. +export const runtime = "nodejs"; + +// Nunca cachear: son datos vivos y peticiones con credenciales. +export const dynamic = "force-dynamic"; + +const API_URL = (process.env.SAM_API_URL ?? "http://127.0.0.1:5000").replace(/\/+$/, ""); +const API_KEY = (process.env.SAM_API_KEY ?? "").trim(); + +/** Métodos que admiten cuerpo. */ +const WITH_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +async function proxy(req: NextRequest, path: string[]) { + if (!API_KEY) { + // Fail-closed: sin clave configurada no se tiene sentido hablar con la API. + return NextResponse.json( + { message: "SAM_API_KEY no configurada en el servidor." }, + { status: 503 }, + ); + } + + // Reconstruye la ruta preservando el query string original. + const target = new URL(`${API_URL}/api/proyectos/${path.join("/")}${req.nextUrl.search}`); + + const headers: Record = { "X-Api-Key": API_KEY }; + const contentType = req.headers.get("content-type"); + if (contentType) headers["Content-Type"] = contentType; + + const body = WITH_BODY.has(req.method) ? await req.text() : undefined; + + try { + const upstream = await fetch(target, { + method: req.method, + headers, + body, + cache: "no-store", + }); + + // Reenvía la respuesta tal cual (código y cuerpo), sin interpretarla. + const text = await upstream.text(); + return new NextResponse(text, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("content-type") ?? "application/json", + }, + }); + } catch { + return NextResponse.json( + { message: "No se pudo contactar con SAM.API." }, + { status: 502 }, + ); + } +} + +export function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return params.then((p) => proxy(req, p.path)); +} + +export function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return params.then((p) => proxy(req, p.path)); +} + +export function PATCH(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return params.then((p) => proxy(req, p.path)); +} + +export function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return params.then((p) => proxy(req, p.path)); +} diff --git a/src/app/board/page.tsx b/src/app/board/page.tsx new file mode 100644 index 0000000..7e708c4 --- /dev/null +++ b/src/app/board/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { Issue, IssueStatus } from "@/lib/types"; +import { AppShell } from "@/components/layout/AppShell"; +import { Board } from "@/components/board/Board"; + +/** + * Vista de tablero Kanban. + * + * Mueve los issues de columna arrastrando y soltar. El cambio se aplica de + * forma optimista y se persiste contra la API. + */ +export default function BoardPage() { + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + void api.listIssues().then((result) => { + if (!cancelled) { + setIssues(result); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + const handleMove = useCallback(async (issueId: string, status: IssueStatus) => { + // Actualización optimista: reflejar el cambio al instante. + setIssues((current) => + current.map((issue) => (issue.id === issueId ? { ...issue, status } : issue)), + ); + + try { + const updated = await api.updateIssue(issueId, { status }); + setIssues((current) => + current.map((issue) => (issue.id === issueId ? updated : issue)), + ); + } catch { + // Revertir consultando de nuevo si falla la escritura. + setIssues(await api.listIssues()); + } + }, []); + + return ( + + + + ); +} diff --git a/src/app/cycles/page.tsx b/src/app/cycles/page.tsx new file mode 100644 index 0000000..5ce9dee --- /dev/null +++ b/src/app/cycles/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { Cycle, Issue } from "@/lib/types"; +import { formatShortDate } from "@/lib/format"; +import { percent } from "@/lib/format"; +import { AppShell } from "@/components/layout/AppShell"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { ProgressBar } from "@/components/ui/ProgressBar"; + +const CICLO_META: Record = { + upcoming: { label: "Próximo", color: "#95a2b3" }, + active: { label: "Activo", color: "#4cb782" }, + completed: { label: "Completado", color: "#5e6ad2" }, +}; + +/** + * Ciclos (sprints) con su progreso. + * + * Carga los ciclos y los issues asociados, y calcula el porcentaje de + * completados por ciclo para mostrar la barra de progreso. + */ +export default function CyclesPage() { + const [cycles, setCycles] = useState([]); + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + void Promise.all([api.listCycles(), api.listIssues()]).then(([cycleData, issueData]) => { + if (!cancelled) { + setCycles(cycleData); + setIssues(issueData); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + // Ciclos ordenados: activo primero, luego próximos, luego completados. + const order: Record = { active: 0, upcoming: 1, completed: 2 }; + const sorted = [...cycles].sort((a, b) => order[a.status] - order[b.status]); + + return ( + + {loading && ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ )} + + {!loading && sorted.length === 0 && ( + + )} + + {!loading && ( +
+ {sorted.map((cycle) => { + const cycleIssues = issues.filter((issue) => issue.cycleId === cycle.id); + const doneCount = cycleIssues.filter((issue) => issue.status === "done").length; + const meta = CICLO_META[cycle.status]; + + return ( +
+
+ {cycle.name} + + {meta.label} + + + {formatShortDate(cycle.startsAt)} – {formatShortDate(cycle.endsAt)} + + + {doneCount}/{cycleIssues.length} issues + +
+ + + + {cycleIssues.length > 0 && ( +
    + {cycleIssues.slice(0, 4).map((issue) => ( +
  • + + {issue.identifier} + {issue.title} + +
  • + ))} + + {cycleIssues.length > 4 && ( +
  • + +{cycleIssues.length - 4} más +
  • + )} +
+ )} +
+ ); + })} +
+ )} + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..fac1baa --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,844 @@ +@import url("https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600;700&display=swap"); + +/* ============================================================ + Proyectos — tema SAM (igual que sam-portal / SistemaCalidad) + Sidebar #00285D colapsable 78px <-> 250px, iconos boxicons, + hero sam-toolbar, fondo #e9edf4. Bootstrap 5 para el resto. + ============================================================ */ + +:root { + --primary: #00285d; + --primary-light: #1a3f7a; + --primary-gradient: linear-gradient(135deg, #00285d 0%, #1a4a8a 100%); + --inv-blue: #015fc9; + --inv-muted: #6c788b; + --card-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + --card-shadow-hover: 0 6px 24px rgba(0, 0, 0, 0.14); + --radius: 12px; + --transition: 0.25s ease; + + --sidebar-collapsed: 78px; + --sidebar-expanded: 250px; +} + +* { + -webkit-tap-highlight-color: transparent; +} + +body { + font-family: "Work Sans", system-ui, sans-serif; + background-color: #e9edf4; + color: #16243d; + min-height: 100vh; + font-size: 14px; +} + +a { + color: var(--inv-blue); + text-decoration: none; +} + +a:hover { + color: #014ea3; +} + +/* --------------------------------------------------------------- + Estructura: SamShell + --------------------------------------------------------------- */ + +.sam-shell .sam-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: var(--sidebar-collapsed); + background: var(--primary); + z-index: 1030; + transition: width var(--transition), transform var(--transition); + display: flex; + flex-direction: column; + overflow-x: hidden; +} + +.sam-shell.open .sam-sidebar { + width: var(--sidebar-expanded); +} + +.sam-sidebar .logo-details { + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + color: #fff; + white-space: nowrap; +} + +.sam-sidebar .logo-details .logo_name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 1.1rem; + opacity: 0; + transition: opacity var(--transition); +} + +.sam-shell.open .logo-details .logo_name { + opacity: 1; +} + +.sam-sidebar .logo-details #btn { + font-size: 1.5rem; + cursor: pointer; + min-width: 50px; + text-align: center; + color: #fff; +} + +.sam-sidebar .nav-list { + list-style: none; + padding: 8px; + margin: 0; + overflow-y: auto; + flex: 1; +} + +.sam-sidebar .nav-list li { + position: relative; + margin: 4px 0; +} + +.sam-sidebar .nav-list li a { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + color: #dbe4f3; + text-decoration: none; + white-space: nowrap; + font-size: 0.9rem; +} + +.sam-sidebar .nav-list li a i { + font-size: 1.4rem; + min-width: 30px; + text-align: center; +} + +.sam-sidebar .nav-list li a:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.sam-sidebar .nav-list li a.active { + background: #fff; + color: var(--primary); + font-weight: 600; +} + +.sam-sidebar .links_name { + opacity: 0; + transition: opacity var(--transition); + white-space: nowrap; +} + +.sam-shell.open .links_name { + opacity: 1; +} + +.sam-shell:not(.open) .links_name, +.sam-shell:not(.open) .name_job, +.sam-shell:not(.open) .brand-text { + display: none; +} + +.sam-shell:not(.open) .logo-details { + justify-content: center; + padding: 0; +} + +.sam-shell:not(.open) .profile-details { + justify-content: center; +} + +.sam-shell:not(.open) .profile-details .bx-log-out { + margin-left: 0 !important; +} + +.sam-sidebar .tooltip { + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + background: #fff; + color: var(--primary); + padding: 4px 10px; + border-radius: 6px; + font-size: 0.85rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + box-shadow: var(--card-shadow); + z-index: 10; +} + +.sam-shell:not(.open) .nav-list li:hover .tooltip { + opacity: 1; +} + +.sam-shell.open .tooltip { + display: none; +} + +.sam-sidebar .profile { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.15); + color: #dbe4f3; +} + +.sam-sidebar .profile-details { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.sam-sidebar .profile-details > i { + font-size: 1.8rem; +} + +.sam-sidebar .name_job { + opacity: 0; +} + +.sam-shell.open .name_job { + opacity: 1; +} + +.sam-sidebar .name_job .name { + font-size: 0.9rem; + font-weight: 600; + color: #fff; +} + +.sam-sidebar .name_job .job { + font-size: 0.75rem; + opacity: 0.7; +} + +.sam-shell .home-section { + margin-left: var(--sidebar-collapsed); + min-height: 100vh; + transition: margin-left var(--transition); +} + +.sam-shell.open .home-section { + margin-left: var(--sidebar-expanded); +} + +/* Contador de una cifra en el sidebar */ +.nav-badge { + margin-left: auto; + background: rgba(255, 255, 255, 0.15); + color: #fff; + border-radius: 10px; + font-size: 0.7rem; + padding: 1px 7px; + opacity: 0; + transition: opacity var(--transition); +} + +.sam-shell.open .nav-badge { + opacity: 1; +} + +/* --------------------------------------------------------------- + Toolbar (hero) + --------------------------------------------------------------- */ + +.sam-toolbar { + background: linear-gradient(135deg, #16243d 0%, #015fc9 100%); + border-radius: var(--radius); + box-shadow: 0 0.3rem 0.8rem rgba(22, 36, 61, 0.12); + padding: 0.6rem 1rem; + color: #fff; +} + +.sam-hero-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.sam-hero-icon { + width: 34px; + height: 34px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.15); + border-radius: 8px; + font-size: 1.2rem; +} + +.sam-hero-title { + font-size: 1rem; + font-weight: 700; + line-height: 1.2; +} + +.sam-hero-sub { + font-size: 0.78rem; + opacity: 0.8; + line-height: 1.2; +} + +/* --------------------------------------------------------------- + Contenido + --------------------------------------------------------------- */ + +.page-pad { + padding: 16px 20px 40px; +} + +.lp-card { + background: #fff; + border-radius: var(--radius); + box-shadow: var(--card-shadow); + transition: box-shadow var(--transition); +} + +.lp-card:hover { + box-shadow: var(--card-shadow-hover); +} + +.section-header { + border-radius: var(--radius); + border-left: 4px solid var(--primary); + font-weight: 600; + font-size: 1.05rem; + background: #fff; +} + +/* --------------------------------------------------------------- + Filas de issue + --------------------------------------------------------------- */ + +.issue-table { + --table-border-color: #e6eaf1; +} + +.issue-row { + cursor: pointer; + transition: background 0.12s ease; +} + +.issue-row:hover { + background: #f4f7fb; +} + +.issue-cell { + padding: 9px 12px; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; +} + +.issue-title-cell { + white-space: normal; +} + +.issue-identifier { + font-size: 0.78rem; + color: var(--inv-muted); + font-variant-numeric: tabular-nums; +} + +.issue-title { + font-size: 0.88rem; + color: #16243d; +} + +.issue-title-input { + background: transparent; + border: none; + border-bottom: 1px solid transparent; + color: #16243d; + font-size: 0.88rem; + padding: 2px 0; + width: 100%; +} + +.issue-title-input:focus { + outline: none; + border-bottom-color: var(--inv-blue); +} + +/* --------------------------------------------------------------- + Agrupación por estado + --------------------------------------------------------------- */ + +.group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: #f0f3f8; + border-top: 1px solid #e6eaf1; + border-bottom: 1px solid #e6eaf1; + font-size: 0.8rem; + font-weight: 600; + color: var(--inv-muted); +} + +/* --------------------------------------------------------------- + Avatares y etiquetas + --------------------------------------------------------------- */ + +.avatar { + width: 24px; + height: 24px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 600; + color: #fff; + flex-shrink: 0; + letter-spacing: 0.02em; +} + +.avatar-lg { + width: 34px; + height: 34px; + font-size: 0.82rem; +} + +.avatar-unassigned { + background: #eef1f6; + border: 1px dashed #c3cbd9; + color: var(--inv-muted); +} + +.label-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.72rem; + padding: 1px 8px; + border-radius: 10px; + background: #eef1f6; + color: var(--inv-muted); + border: 1px solid #e0e5ee; +} + +.label-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +/* --------------------------------------------------------------- + Kanban + --------------------------------------------------------------- */ + +.board { + display: flex; + gap: 12px; + overflow-x: auto; + align-items: flex-start; +} + +.board-column { + width: 300px; + flex-shrink: 0; + background: #f5f7fb; + border: 1px solid #e0e5ee; + border-radius: 10px; + display: flex; + flex-direction: column; + max-height: calc(100vh - 200px); +} + +.board-column-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid #e0e5ee; + font-size: 0.8rem; + font-weight: 600; + color: var(--inv-muted); +} + +.board-column-body { + padding: 8px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 80px; +} + +.board-column.drag-over { + outline: 2px dashed var(--inv-blue); + outline-offset: -2px; +} + +.board-card { + background: #fff; + border: 1px solid #e0e5ee; + border-radius: 8px; + padding: 10px; + cursor: grab; + box-shadow: var(--card-shadow); + transition: border-color 0.12s ease; +} + +.board-card:hover { + border-color: #b9c4d6; +} + +.board-card:active { + cursor: grabbing; +} + +.board-card.dragging { + opacity: 0.4; +} + +.board-card-title { + font-size: 0.85rem; + color: #16243d; + margin: 6px 0 8px; +} + +/* --------------------------------------------------------------- + Barra de progreso + --------------------------------------------------------------- */ + +.progress-lp { + height: 6px; + background: #e3e8f0; + border-radius: 3px; + overflow: hidden; +} + +.progress-lp-bar { + height: 100%; + background: var(--inv-blue); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* --------------------------------------------------------------- + Detalle de issue + --------------------------------------------------------------- */ + +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 32px; +} + +.detail-title { + font-size: 1.5rem; + font-weight: 700; + line-height: 1.3; + margin: 0; + color: #16243d; +} + +.detail-description { + color: var(--inv-muted); + font-size: 0.9rem; + white-space: pre-wrap; + line-height: 1.65; +} + +.detail-section-title { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--inv-muted); + margin-bottom: 8px; +} + +.prop-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 0; + font-size: 0.85rem; +} + +.prop-label { + color: var(--inv-muted); +} + +.prop-select { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: #16243d; + font-size: 0.85rem; + padding: 3px 6px; + max-width: 170px; + cursor: pointer; +} + +.prop-select:hover, +.prop-select:focus { + background: #f4f7fb; + border-color: #e0e5ee; + outline: none; +} + +.comment { + display: flex; + gap: 12px; + padding: 14px 0; + border-top: 1px solid #e6eaf1; +} + +.comment-body { + font-size: 0.88rem; + color: var(--inv-muted); + white-space: pre-wrap; +} + +/* --------------------------------------------------------------- + Estados vacíos y loading + --------------------------------------------------------------- */ + +.empty-state { + text-align: center; + padding: 64px 24px; + color: var(--inv-muted); +} + +.empty-state-icon { + font-size: 1.8rem; + margin-bottom: 12px; + opacity: 0.6; +} + +.skeleton { + background: linear-gradient(90deg, #e9edf4 25%, #f3f6fa 50%, #e9edf4 75%); + background-size: 200% 100%; + animation: lp-shimmer 1.4s infinite; + border-radius: 4px; +} + +@keyframes lp-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* --------------------------------------------------------------- + Command palette (Cmd/Ctrl + K) + --------------------------------------------------------------- */ + +.palette-backdrop { + position: fixed; + inset: 0; + background: rgba(22, 36, 61, 0.45); + z-index: 1050; + display: flex; + justify-content: center; + padding-top: 12vh; +} + +.palette { + width: min(620px, 92vw); + background: #fff; + border-radius: var(--radius); + overflow: hidden; + box-shadow: 0 24px 60px rgba(22, 36, 61, 0.28); +} + +.palette-input { + width: 100%; + background: transparent; + border: none; + border-bottom: 1px solid #e6eaf1; + color: #16243d; + font-size: 0.95rem; + padding: 14px 16px; +} + +.palette-input:focus { + outline: none; +} + +.palette-list { + max-height: 340px; + overflow-y: auto; + padding: 6px; + margin: 0; + list-style: none; +} + +.palette-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 7px; + cursor: pointer; + font-size: 0.88rem; + color: var(--inv-muted); +} + +.palette-item.selected { + background: #e8f0fd; + color: #16243d; +} + +.palette-item i { + width: 18px; + text-align: center; + flex-shrink: 0; +} + +/* --------------------------------------------------------------- + Filtros + --------------------------------------------------------------- */ + +.filter-bar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.78rem; + padding: 3px 10px; + border-radius: 6px; + border: 1px solid #e0e5ee; + background: #fff; + color: var(--inv-muted); + transition: all 0.12s ease; +} + +.filter-chip:hover { + background: #f4f7fb; + color: #16243d; +} + +.filter-chip.active { + background: #e8f0fd; + border-color: var(--inv-blue); + color: var(--primary); +} + +/* --------------------------------------------------------------- + Botones + --------------------------------------------------------------- */ + +.btn-lp { + --bs-btn-bg: #fff; + --bs-btn-border-color: #dbe1ec; + --bs-btn-color: #16243d; + --bs-btn-hover-bg: #f4f7fb; + --bs-btn-hover-border-color: #c3cbd9; + --bs-btn-hover-color: #16243d; + --bs-btn-active-bg: #eaeff7; + --bs-btn-active-color: #16243d; + --bs-btn-focus-shadow-rgb: 1, 95, 201; + font-size: 0.82rem; +} + +.btn-accent { + --bs-btn-bg: var(--inv-blue); + --bs-btn-border-color: var(--inv-blue); + --bs-btn-color: #fff; + --bs-btn-hover-bg: #014ea3; + --bs-btn-hover-border-color: #014ea3; + --bs-btn-hover-color: #fff; + --bs-btn-active-bg: #014a99; + --bs-btn-active-border-color: #014a99; + --bs-btn-focus-shadow-rgb: 1, 95, 201; + font-size: 0.82rem; +} + +/* --------------------------------------------------------------- + Login + --------------------------------------------------------------- */ + +.login-page .sidenav { + background: var(--primary); +} + +.login-page .login-main-text { + padding: 60px 40px; + color: #fff; +} + +.login-page .login-main-text img { + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); + width: 80px; + height: 80px; + object-fit: contain; +} + +/* --------------------------------------------------------------- + Responsive + --------------------------------------------------------------- */ + +@media screen and (max-width: 767px) { + .sam-shell .sam-sidebar { + width: var(--sidebar-expanded); + transform: translateX(-100%); + } + + .sam-shell.open .sam-sidebar { + transform: translateX(0); + } + + .sam-shell .home-section, + .sam-shell.open .home-section { + margin-left: 0; + } + + .detail-grid { + grid-template-columns: 1fr; + gap: 24px; + } + + .issue-cell-secondary { + display: none; + } +} + +@media screen and (min-width: 768px) { + .login-page .sidenav { + width: 40%; + position: fixed; + top: 0; + left: 0; + height: 100vh; + } + + .login-page .main { + margin-left: 40%; + } +} diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx new file mode 100644 index 0000000..9c6c362 --- /dev/null +++ b/src/app/inbox/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { api } from "@/lib/api"; +import type { Issue } from "@/lib/types"; +import { useAuth } from "@/lib/auth"; +import { AppShell } from "@/components/layout/AppShell"; +import { IssueList } from "@/components/issues/IssueList"; + +/** + * Bandeja personal: los issues abiertos asignados al usuario en sesión, + * ordenados por prioridad y fecha de actualización. + */ +export default function InboxPage() { + const { user } = useAuth(); + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + + const userId = user?.id; + + useEffect(() => { + // Sin sesión todavía no hay a quién filtrar. + if (!userId) return; + + let cancelled = false; + + void api.listIssues({ assigneeId: [userId] }).then((result) => { + if (!cancelled) { + setIssues(result); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [userId]); + + const openIssues = useMemo(() => { + const open = issues.filter((issue) => issue.status !== "done" && issue.status !== "canceled"); + + // Mayor prioridad primero; dentro de un mismo nivel, lo más reciente. + return [...open].sort((a, b) => { + if (a.priority !== b.priority) return b.priority - a.priority; + return b.updatedAt.localeCompare(a.updatedAt); + }); + }, [issues]); + + return ( + +
+ + {openIssues.length}{" "} + {openIssues.length === 1 ? "issue abierto asignado" : "issues abiertos asignados"} a ti + +
+ +
+ +
+
+ ); +} diff --git a/src/app/issues/[id]/page.tsx b/src/app/issues/[id]/page.tsx new file mode 100644 index 0000000..81df2e5 --- /dev/null +++ b/src/app/issues/[id]/page.tsx @@ -0,0 +1,336 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import type { Comment, Issue, IssueStatus } from "@/lib/types"; +import { PRIORITY_META, STATUS_META, STATUS_ORDER, TYPE_META } from "@/lib/meta"; +import { formatLongDate, formatRelative } from "@/lib/format"; +import { useReference, useReferenceLookups } from "@/lib/store"; +import { useAuth } from "@/lib/auth"; +import { AppShell } from "@/components/layout/AppShell"; +import { Avatar } from "@/components/ui/Avatar"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { TypeIcon } from "@/components/ui/Icons"; + +/** + * Detalle de un issue: descripción, propiedades editables y comentarios. + * + * Carga el issue por id y lo mantiene en estado local, de modo que cada + * cambio de propiedad se refleja al instante sin volver a consultar. + */ +export default function IssueDetailPage() { + const params = useParams<{ id: string }>(); + const issueId = params.id; + + return ( + + + + ); +} + +function IssueDetail({ issueId }: { issueId: string }) { + const router = useRouter(); + const { user } = useAuth(); + const { users, projects, cycles, labels } = useReference(); + const { projectById, userById } = useReferenceLookups(); + + const [issue, setIssue] = useState(null); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [draft, setDraft] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + const [found, issueComments] = await Promise.all([ + api.getIssue(issueId), + api.listComments(issueId), + ]); + + setIssue(found); + setComments(issueComments); + setLoading(false); + }, [issueId]); + + useEffect(() => { + void load(); + }, [load]); + + /** Aplica un parche al issue local y lo persiste en segundo plano. */ + async function patch(partial: Partial) { + if (!issue) return; + + // Actualización optimista. + setIssue((current) => (current ? { ...current, ...partial } : current)); + + try { + const updated = await api.updateIssue(issue.id, partial); + setIssue(updated); + } catch { + // Revertir si el backend rechaza el cambio. + void load(); + } + } + + async function handleComment(event: React.FormEvent) { + event.preventDefault(); + const body = draft.trim(); + // Sin sesión no hay autor válido: el backend lo rechazaría. + if (!body || !user) return; + + setDraft(""); + const created = await api.addComment(issueId, body, user.id); + setComments((current) => [...current, created]); + } + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (!issue) { + return ( + router.push("/issues")}> + Volver a Issues + + } + /> + ); + } + + const project = issue.projectId ? projectById.get(issue.projectId) : undefined; + + return ( +
+ {/* Columna principal */} +
+
+ {project && ( + + + {project.name} + + )} + / + {issue.identifier} +
+ +

{issue.title}

+ +
+ + + {TYPE_META[issue.type].label} + + · + + Creado {formatRelative(issue.createdAt)} + +
+ +
+ {issue.description || ( + Sin descripción. + )} +
+ + {/* Comentarios */} +
+
Actividad
+ + {comments.length === 0 && ( +

+ Sin comentarios todavía. +

+ )} + + {comments.map((comment) => { + const author = userById.get(comment.authorId); + return ( +
+ +
+
+ + {author?.name ?? "Desconocido"} + + + {formatRelative(comment.createdAt)} + +
+
{comment.body}
+
+
+ ); + })} + +
+