feat: modulo de proyectos frontend (Next 16 + Bootstrap 5)
This commit is contained in:
commit
2dc41ed4fa
43 files changed
+5894
No files matched your search
@@ -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
|
||||||
+28
@@ -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
|
||||||
+32
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+1382
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> = { "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));
|
||||||
|
}
|
||||||
@@ -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<Issue[]>([]);
|
||||||
|
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 (
|
||||||
|
<AppShell title="Tablero">
|
||||||
|
<Board issues={issues} loading={loading} onMove={handleMove} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Cycle["status"], { label: string; color: string }> = {
|
||||||
|
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<Cycle[]>([]);
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
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<Cycle["status"], number> = { active: 0, upcoming: 1, completed: 2 };
|
||||||
|
const sorted = [...cycles].sort((a, b) => order[a.status] - order[b.status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title="Ciclos">
|
||||||
|
{loading && (
|
||||||
|
<div className="p-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} className="skeleton mb-3" style={{ height: 96, borderRadius: 10 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && sorted.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon="bx-reset"
|
||||||
|
title="No hay ciclos definidos"
|
||||||
|
description="Los ciclos agrupan los issues por periodos de trabajo."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="p-4 d-flex flex-column gap-3">
|
||||||
|
{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 (
|
||||||
|
<article
|
||||||
|
key={cycle.id}
|
||||||
|
className="border rounded-3 p-3"
|
||||||
|
style={{ background: "var(--lp-bg-elevated)", borderColor: "var(--lp-border)" }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{cycle.name}</span>
|
||||||
|
<span
|
||||||
|
className="label-chip"
|
||||||
|
style={{ color: meta.color, borderColor: meta.color }}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
|
||||||
|
{formatShortDate(cycle.startsAt)} – {formatShortDate(cycle.endsAt)}
|
||||||
|
</span>
|
||||||
|
<span className="ms-auto" style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
|
||||||
|
{doneCount}/{cycleIssues.length} issues
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
value={percent(doneCount, cycleIssues.length)}
|
||||||
|
color={cycle.status === "completed" ? "#5e6ad2" : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cycleIssues.length > 0 && (
|
||||||
|
<ul className="list-unstyled mt-3 mb-0">
|
||||||
|
{cycleIssues.slice(0, 4).map((issue) => (
|
||||||
|
<li key={issue.id} className="py-1">
|
||||||
|
<Link
|
||||||
|
href={`/issues/${issue.id}`}
|
||||||
|
className="d-flex align-items-center gap-2"
|
||||||
|
style={{ color: "var(--lp-text-muted)", fontSize: 13 }}
|
||||||
|
>
|
||||||
|
<span className="issue-identifier">{issue.identifier}</span>
|
||||||
|
<span className="text-truncate">{issue.title}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{cycleIssues.length > 4 && (
|
||||||
|
<li style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
|
||||||
|
+{cycleIssues.length - 4} más
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Issue[]>([]);
|
||||||
|
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 (
|
||||||
|
<AppShell title="Bandeja" subtitle={user?.name}>
|
||||||
|
<div className="filter-bar px-4 py-3 border-bottom">
|
||||||
|
<span style={{ fontSize: "0.85rem", color: "var(--inv-muted)" }}>
|
||||||
|
{openIssues.length}{" "}
|
||||||
|
{openIssues.length === 1 ? "issue abierto asignado" : "issues abiertos asignados"} a ti
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-pad pt-3">
|
||||||
|
<IssueList
|
||||||
|
issues={openIssues}
|
||||||
|
loading={loading || !userId}
|
||||||
|
groupBy="status"
|
||||||
|
emptyTitle="Bandeja limpia"
|
||||||
|
emptyDescription="No tienes issues abiertos asignados."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AppShell title="Issue" subtitle={`#${issueId}`}>
|
||||||
|
<IssueDetail issueId={issueId} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Issue | null>(null);
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
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<Issue>) {
|
||||||
|
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 (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="skeleton mb-3" style={{ height: 28, width: "60%" }} />
|
||||||
|
<div className="skeleton mb-2" style={{ height: 14, width: "90%" }} />
|
||||||
|
<div className="skeleton" style={{ height: 14, width: "75%" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issue) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="bx-error-circle"
|
||||||
|
title="Issue no encontrado"
|
||||||
|
description={`No existe el issue ${issueId}.`}
|
||||||
|
action={
|
||||||
|
<button type="button" className="btn btn-lp btn-sm" onClick={() => router.push("/issues")}>
|
||||||
|
Volver a Issues
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = issue.projectId ? projectById.get(issue.projectId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-grid">
|
||||||
|
{/* Columna principal */}
|
||||||
|
<div>
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-2">
|
||||||
|
{project && (
|
||||||
|
<Link
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className="issue-identifier d-flex align-items-center gap-1"
|
||||||
|
>
|
||||||
|
<i className="bx bx-folder" />
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="issue-identifier">/</span>
|
||||||
|
<span className="issue-identifier">{issue.identifier}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="detail-title mb-3">{issue.title}</h1>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-4">
|
||||||
|
<TypeIcon type={issue.type} size={16} />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-muted)" }}>
|
||||||
|
{TYPE_META[issue.type].label}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--lp-text-faint)" }}>·</span>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-faint)" }}>
|
||||||
|
Creado {formatRelative(issue.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-description mb-5">
|
||||||
|
{issue.description || (
|
||||||
|
<span style={{ color: "var(--lp-text-faint)" }}>Sin descripción.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comentarios */}
|
||||||
|
<section>
|
||||||
|
<div className="detail-section-title">Actividad</div>
|
||||||
|
|
||||||
|
{comments.length === 0 && (
|
||||||
|
<p style={{ color: "var(--lp-text-faint)", fontSize: 13 }}>
|
||||||
|
Sin comentarios todavía.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comments.map((comment) => {
|
||||||
|
const author = userById.get(comment.authorId);
|
||||||
|
return (
|
||||||
|
<div key={comment.id} className="comment">
|
||||||
|
<Avatar user={author} size="lg" />
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<div className="d-flex align-items-baseline gap-2 mb-1">
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{author?.name ?? "Desconocido"}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--lp-text-faint)" }}>
|
||||||
|
{formatRelative(comment.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="comment-body">{comment.body}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<form onSubmit={handleComment} className="mt-3">
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Escribe un comentario…"
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
style={{
|
||||||
|
background: "var(--lp-bg-elevated)",
|
||||||
|
border: "1px solid var(--lp-border)",
|
||||||
|
color: "var(--lp-text)",
|
||||||
|
fontSize: 13.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-accent btn-sm mt-2"
|
||||||
|
disabled={!draft.trim() || !user}
|
||||||
|
>
|
||||||
|
Comentar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna de propiedades */}
|
||||||
|
<aside>
|
||||||
|
<div className="detail-section-title">Propiedades</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Estado</span>
|
||||||
|
<select
|
||||||
|
className="prop-select"
|
||||||
|
value={issue.status}
|
||||||
|
onChange={(event) => patch({ status: event.target.value as IssueStatus })}
|
||||||
|
>
|
||||||
|
{STATUS_ORDER.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{STATUS_META[status].label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Prioridad</span>
|
||||||
|
<select
|
||||||
|
className="prop-select"
|
||||||
|
value={issue.priority}
|
||||||
|
onChange={(event) =>
|
||||||
|
patch({ priority: Number(event.target.value) as Issue["priority"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[0, 4, 3, 2, 1].map((priority) => (
|
||||||
|
<option key={priority} value={priority}>
|
||||||
|
{PRIORITY_META[priority as Issue["priority"]].label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Asignado</span>
|
||||||
|
<select
|
||||||
|
className="prop-select"
|
||||||
|
value={issue.assigneeId ?? ""}
|
||||||
|
onChange={(event) => patch({ assigneeId: event.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Sin asignar</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Proyecto</span>
|
||||||
|
<select
|
||||||
|
className="prop-select"
|
||||||
|
value={issue.projectId ?? ""}
|
||||||
|
onChange={(event) => patch({ projectId: event.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Sin proyecto</option>
|
||||||
|
{projects.map((projectOption) => (
|
||||||
|
<option key={projectOption.id} value={projectOption.id}>
|
||||||
|
{projectOption.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Ciclo</span>
|
||||||
|
<select
|
||||||
|
className="prop-select"
|
||||||
|
value={issue.cycleId ?? ""}
|
||||||
|
onChange={(event) => patch({ cycleId: event.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Sin ciclo</option>
|
||||||
|
{cycles.map((cycleOption) => (
|
||||||
|
<option key={cycleOption.id} value={cycleOption.id}>
|
||||||
|
{cycleOption.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-3" style={{ borderColor: "var(--lp-border)" }} />
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Etiquetas</span>
|
||||||
|
<span className="d-flex flex-wrap gap-1 justify-content-end">
|
||||||
|
{issue.labelIds.length === 0 && (
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-faint)" }}>—</span>
|
||||||
|
)}
|
||||||
|
{issue.labelIds
|
||||||
|
.map((id) => labels.find((l) => l.id === id))
|
||||||
|
.filter((label): label is NonNullable<typeof label> => Boolean(label))
|
||||||
|
.map((label) => (
|
||||||
|
<span key={label.id} className="label-chip">
|
||||||
|
<span className="label-dot" style={{ background: label.color }} />
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Creado</span>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-faint)" }}>
|
||||||
|
{formatLongDate(issue.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Actualizado</span>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-faint)" }}>
|
||||||
|
{formatLongDate(issue.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issue.completedAt && (
|
||||||
|
<div className="prop-row">
|
||||||
|
<span className="prop-label">Completado</span>
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-faint)" }}>
|
||||||
|
{formatLongDate(issue.completedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Issue, IssueInput, IssueStatus } from "@/lib/types";
|
||||||
|
import { STATUS_META, STATUS_ORDER } from "@/lib/meta";
|
||||||
|
import { useIssues } from "@/hooks/useIssues";
|
||||||
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
|
import { IssueList } from "@/components/issues/IssueList";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Página principal de issues.
|
||||||
|
*
|
||||||
|
* Agrupa por estado y permite filtrar. Acepta `?new=1` en la URL para abrir
|
||||||
|
* el formulario de creación directamente (usado por el botón «Nuevo issue» y
|
||||||
|
* por la palette de comandos).
|
||||||
|
*/
|
||||||
|
export default function IssuesPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<IssuesPageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssuesPageInner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<IssueStatus[]>([]);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
|
||||||
|
// Abrir el formulario cuando la URL lo indica.
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("new") === "1") {
|
||||||
|
setCreating(true);
|
||||||
|
// Quitar el parámetro para no reabrirlo al navegar dentro de la página.
|
||||||
|
router.replace("/issues");
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
const { issues, loading } = useIssues(
|
||||||
|
statusFilter.length ? { status: statusFilter } : undefined,
|
||||||
|
refreshKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleStatus(status: IssueStatus) {
|
||||||
|
setStatusFilter((current) =>
|
||||||
|
current.includes(status)
|
||||||
|
? current.filter((s) => s !== status)
|
||||||
|
: [...current, status],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const input: IssueInput = { title: trimmed, status: "todo" };
|
||||||
|
await api.createIssue(input);
|
||||||
|
|
||||||
|
setTitle("");
|
||||||
|
setCreating(false);
|
||||||
|
setRefreshKey((key) => key + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
{STATUS_ORDER.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
className={`filter-chip${statusFilter.includes(status) ? " active" : ""}`}
|
||||||
|
onClick={() => toggleStatus(status)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label-dot"
|
||||||
|
style={{ background: STATUS_META[status].color }}
|
||||||
|
/>
|
||||||
|
{STATUS_META[status].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title="Issues" actions={actions}>
|
||||||
|
{creating && (
|
||||||
|
<div className="border-bottom p-3" style={{ background: "var(--lp-bg-elevated)" }}>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="issue-title-input"
|
||||||
|
style={{ fontSize: 15 }}
|
||||||
|
placeholder="Issue title…"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") setCreating(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" className="btn btn-accent btn-sm" disabled={!title.trim()}>
|
||||||
|
Crear issue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-lp btn-sm"
|
||||||
|
onClick={() => setCreating(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!creating && (
|
||||||
|
<div className="filter-bar">
|
||||||
|
<button type="button" className="filter-chip" onClick={() => setCreating(true)}>
|
||||||
|
<i className="bx bx-plus" />
|
||||||
|
Nuevo issue
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{statusFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="filter-chip"
|
||||||
|
onClick={() => setStatusFilter([])}
|
||||||
|
>
|
||||||
|
<i className="bx bx-x" />
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IssueList
|
||||||
|
issues={issues}
|
||||||
|
loading={loading}
|
||||||
|
groupBy="status"
|
||||||
|
emptyTitle={
|
||||||
|
statusFilter.length > 0 ? "Ningún issue coincide con los filtros" : "No hay issues aquí"
|
||||||
|
}
|
||||||
|
emptyDescription={
|
||||||
|
statusFilter.length > 0
|
||||||
|
? "Prueba a ampliar la selección de estados."
|
||||||
|
: "Crea un nuevo issue para empezar."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import "boxicons/css/boxicons.min.css";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/lib/auth";
|
||||||
|
import { ReferenceProvider } from "@/lib/store";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "Proyectos · SAM",
|
||||||
|
template: "%s · Proyectos · SAM",
|
||||||
|
},
|
||||||
|
description: "Gestor de proyectos estilo Linear para la plataforma SAM.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout raíz.
|
||||||
|
*
|
||||||
|
* Importa Bootstrap y los iconos a nivel global (cachéados por Next entre
|
||||||
|
* rutas) y monta el proveedor de datos de referencia usado por todas las
|
||||||
|
* páginas del módulo.
|
||||||
|
*/
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="es">
|
||||||
|
<body>
|
||||||
|
<AuthProvider>
|
||||||
|
<ReferenceProvider>{children}</ReferenceProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acceso al módulo. Valida contra sige_sam_v3.usuario en el backend
|
||||||
|
* (POST /api/proyectos/auth/login), igual que el resto de módulos SAM.
|
||||||
|
*
|
||||||
|
* Patrón visual de sam-portal / SistemaCalidad: sidenav azul con la marca
|
||||||
|
* a la izquierda y la tarjeta de acceso a la derecha.
|
||||||
|
*/
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, loading, login } = useAuth();
|
||||||
|
|
||||||
|
const [idUsuario, setIdUsuario] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Si ya hay sesión, no tiene sentido mostrar el login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && user) router.replace("/inbox");
|
||||||
|
}, [loading, user, router]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(idUsuario, password);
|
||||||
|
router.push("/inbox");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error al ingresar");
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="container-fluid">
|
||||||
|
<div className="row min-vh-100">
|
||||||
|
{/* Marca */}
|
||||||
|
<div className="col-md-5 sidenav d-flex flex-column justify-content-center">
|
||||||
|
<div className="login-main-text text-center">
|
||||||
|
<i className="bx bx-briefcase" style={{ fontSize: "5rem" }} />
|
||||||
|
<h1 className="fw-bold mt-3">Proyectos</h1>
|
||||||
|
<p className="mb-0" style={{ opacity: 0.8 }}>
|
||||||
|
Gestión de proyectos · SAM
|
||||||
|
</p>
|
||||||
|
<p style={{ opacity: 0.5, fontSize: "0.85rem" }}>
|
||||||
|
Organismo Técnico de Capacitación
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulario */}
|
||||||
|
<div className="col-md-7 d-flex align-items-center justify-content-center">
|
||||||
|
<div className="lp-card p-4 p-md-5" style={{ maxWidth: 420, width: "100%" }}>
|
||||||
|
<h2 className="h5 fw-bold mb-1">Acceso al sistema</h2>
|
||||||
|
<p className="small mb-4" style={{ color: "var(--inv-muted)" }}>
|
||||||
|
Use su RUT y clave del sistema central (SIGE).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-danger d-flex align-items-center gap-2 py-2" role="alert">
|
||||||
|
<i className="bx bx-error" />
|
||||||
|
<span className="small">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="idUsuario" className="form-label">
|
||||||
|
Usuario (RUT o legajo)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="idUsuario"
|
||||||
|
className="form-control"
|
||||||
|
autoComplete="username"
|
||||||
|
value={idUsuario}
|
||||||
|
onChange={(event) => setIdUsuario(event.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="password" className="form-label">
|
||||||
|
Clave
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
className="form-control"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-accent w-100 py-2 d-flex align-items-center justify-content-center gap-2"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm" role="status" />
|
||||||
|
Verificando…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bx bx-log-in" />
|
||||||
|
Ingresar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La raíz redirige a la bandeja, que es la página de inicio del módulo.
|
||||||
|
*/
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/inbox");
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Issue, Project } from "@/lib/types";
|
||||||
|
import { PROJECT_STATUS_META } from "@/lib/meta";
|
||||||
|
import { formatLongDate, percent } from "@/lib/format";
|
||||||
|
import { useReferenceLookups } from "@/lib/store";
|
||||||
|
import { useIssues } from "@/hooks/useIssues";
|
||||||
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
|
import { IssueList } from "@/components/issues/IssueList";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { ProgressBar } from "@/components/ui/ProgressBar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detalle de un proyecto: cabecera con estado, lead y progreso, más la lista
|
||||||
|
* de issues filtrada por proyecto.
|
||||||
|
*/
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const projectId = params.id;
|
||||||
|
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { userById } = useReferenceLookups();
|
||||||
|
const { issues, loading: issuesLoading } = useIssues({ projectId: [projectId] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void api.listProjects().then((projects) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setProject(projects.find((p) => p.id === projectId) ?? null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AppShell title="Proyecto">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="skeleton mb-3" style={{ height: 28, width: "40%" }} />
|
||||||
|
<div className="skeleton" style={{ height: 14, width: "70%" }} />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<AppShell title="Proyecto">
|
||||||
|
<EmptyState
|
||||||
|
icon="bx-folder-x"
|
||||||
|
title="Proyecto no encontrado"
|
||||||
|
description={`No existe el proyecto ${projectId}.`}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = project.leadId ? userById.get(project.leadId) : undefined;
|
||||||
|
const meta = PROJECT_STATUS_META[project.status];
|
||||||
|
const doneCount = issues.filter((issue) => issue.status === "done").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title={project.name} subtitle={project.key}>
|
||||||
|
<div className="p-4 border-bottom" style={{ borderColor: "var(--lp-border)" }}>
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<span className="label-chip" style={{ color: meta.color }}>
|
||||||
|
<i className={`bx ${meta.icon}`} />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ms-auto d-flex align-items-center gap-2">
|
||||||
|
{lead && (
|
||||||
|
<>
|
||||||
|
<Avatar user={lead} />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--lp-text-muted)" }}>{lead.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 14, color: "var(--lp-text-muted)", maxWidth: 760 }}>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="row g-4 mt-1">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<ProgressBar
|
||||||
|
value={percent(doneCount, issues.length)}
|
||||||
|
label={`Progreso · ${doneCount}/${issues.length} issues`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="d-flex gap-5">
|
||||||
|
{project.startDate && (
|
||||||
|
<div>
|
||||||
|
<div className="detail-section-title">Inicio</div>
|
||||||
|
<div style={{ fontSize: 13 }}>{formatLongDate(project.startDate)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.targetDate && (
|
||||||
|
<div>
|
||||||
|
<div className="detail-section-title">Fecha objetivo</div>
|
||||||
|
<div style={{ fontSize: 13 }}>{formatLongDate(project.targetDate)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IssueList
|
||||||
|
issues={issues}
|
||||||
|
loading={issuesLoading}
|
||||||
|
groupBy="status"
|
||||||
|
emptyTitle="Este proyecto aún no tiene issues"
|
||||||
|
emptyDescription="Crea un issue nuevo y asíignalo a este proyecto."
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Issue, Project } from "@/lib/types";
|
||||||
|
import { PROJECT_STATUS_META } from "@/lib/meta";
|
||||||
|
import { formatShortDate, percent } from "@/lib/format";
|
||||||
|
import { useReferenceLookups } from "@/lib/store";
|
||||||
|
import { AppShell } from "@/components/layout/AppShell";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { ProgressBar } from "@/components/ui/ProgressBar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listado de proyectos con su progreso calculado a partir de los issues
|
||||||
|
* completados sobre el total.
|
||||||
|
*/
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { userById } = useReferenceLookups();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void Promise.all([api.listProjects(), api.listIssues()]).then(([projectData, issueData]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setProjects(projectData);
|
||||||
|
setIssues(issueData);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const progressByProject = useMemo(() => {
|
||||||
|
const map = new Map<string, { total: number; done: number }>();
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (!issue.projectId) continue;
|
||||||
|
const entry = map.get(issue.projectId) ?? { total: 0, done: 0 };
|
||||||
|
entry.total += 1;
|
||||||
|
if (issue.status === "done") entry.done += 1;
|
||||||
|
map.set(issue.projectId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell title="Proyectos">
|
||||||
|
{loading && (
|
||||||
|
<div className="p-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} className="skeleton mb-3" style={{ height: 88, borderRadius: 10 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && projects.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon="bx-folder"
|
||||||
|
title="No hay proyectos"
|
||||||
|
description="Crea un proyecto para agrupar issues relacionados."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="p-4 d-flex flex-column gap-3">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const progress = progressByProject.get(project.id) ?? { total: 0, done: 0 };
|
||||||
|
const lead = project.leadId ? userById.get(project.leadId) : undefined;
|
||||||
|
const meta = PROJECT_STATUS_META[project.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className="border rounded-3 p-3 text-decoration-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--lp-bg-elevated)",
|
||||||
|
borderColor: "var(--lp-border)",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 14, color: "var(--lp-text)" }}>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
<span className="issue-identifier">{project.key}</span>
|
||||||
|
<span className="label-chip ms-1" style={{ color: meta.color }}>
|
||||||
|
<i className={`bx ${meta.icon}`} />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ms-auto d-flex align-items-center gap-2">
|
||||||
|
<span style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
|
||||||
|
{progress.done}/{progress.total}
|
||||||
|
</span>
|
||||||
|
<Avatar user={lead} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="mb-3"
|
||||||
|
style={{ fontSize: 13, color: "var(--lp-text-muted)", maxWidth: 720 }}
|
||||||
|
>
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<ProgressBar value={percent(progress.done, progress.total)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.targetDate && (
|
||||||
|
<span style={{ fontSize: 12, color: "var(--lp-text-faint)", whiteSpace: "nowrap" }}>
|
||||||
|
<i className="bx bx-calendar me-1" />
|
||||||
|
{formatShortDate(project.targetDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { STATUS_META, STATUS_ORDER } from "@/lib/meta";
|
||||||
|
import type { Issue, IssueStatus } from "@/lib/types";
|
||||||
|
import { useReferenceLookups } from "@/lib/store";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { PriorityIcon, StatusIcon } from "@/components/ui/Icons";
|
||||||
|
|
||||||
|
interface BoardProps {
|
||||||
|
issues: Issue[];
|
||||||
|
loading?: boolean;
|
||||||
|
/** Invocado al soltar una tarjeta en otra columna. */
|
||||||
|
onMove?: (issueId: string, status: IssueStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tablero Kanban con arrastrar y soltar nativo del navegador (HTML5 DnD).
|
||||||
|
*
|
||||||
|
* No depende de librerías externas: cada tarjeta arrastrable transmite su id
|
||||||
|
* y cada columna actúa como zona de destino, actualizando el estado del issue
|
||||||
|
* al soltar.
|
||||||
|
*/
|
||||||
|
export function Board({ issues, loading, onMove }: BoardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { userById, projectById, labelById } = useReferenceLookups();
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
const [overStatus, setOverStatus] = useState<IssueStatus | null>(null);
|
||||||
|
|
||||||
|
function handleDrop(event: React.DragEvent, status: IssueStatus) {
|
||||||
|
event.preventDefault();
|
||||||
|
const issueId = event.dataTransfer.getData("text/plain");
|
||||||
|
|
||||||
|
if (issueId) onMove?.(issueId, status);
|
||||||
|
|
||||||
|
setDraggingId(null);
|
||||||
|
setOverStatus(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board">
|
||||||
|
{STATUS_ORDER.map((status) => {
|
||||||
|
const columnIssues = issues.filter((issue) => issue.status === status);
|
||||||
|
const meta = STATUS_META[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className={`board-column${overStatus === status ? " drag-over" : ""}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setOverStatus(status);
|
||||||
|
}}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
// Ignorar eventos que salen hacia un hijo de la columna.
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
|
||||||
|
setOverStatus(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(event) => handleDrop(event, status)}
|
||||||
|
>
|
||||||
|
<div className="board-column-header">
|
||||||
|
<StatusIcon status={status} />
|
||||||
|
<span>{meta.label}</span>
|
||||||
|
<span className="ms-auto" style={{ color: "var(--lp-text-faint)" }}>
|
||||||
|
{columnIssues.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="board-column-body">
|
||||||
|
{loading &&
|
||||||
|
Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<div key={index} className="skeleton" style={{ height: 76, borderRadius: 8 }} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && columnIssues.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="text-center py-4"
|
||||||
|
style={{ color: "var(--lp-text-faint)", fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Arrastra un issue aquí
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
columnIssues.map((issue) => {
|
||||||
|
const assignee = issue.assigneeId
|
||||||
|
? userById.get(issue.assigneeId)
|
||||||
|
: undefined;
|
||||||
|
const project = issue.projectId
|
||||||
|
? projectById.get(issue.projectId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={issue.id}
|
||||||
|
className={`board-card${draggingId === issue.id ? " dragging" : ""}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.setData("text/plain", issue.id);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
setDraggingId(issue.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggingId(null);
|
||||||
|
setOverStatus(null);
|
||||||
|
}}
|
||||||
|
onClick={() => router.push(`/issues/${issue.id}`)}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="issue-identifier">{issue.identifier}</span>
|
||||||
|
{project && (
|
||||||
|
<span
|
||||||
|
className="text-truncate"
|
||||||
|
style={{ fontSize: 11.5, color: "var(--lp-text-faint)" }}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ms-auto">
|
||||||
|
<PriorityIcon priority={issue.priority} size={13} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="board-card-title">{issue.title}</div>
|
||||||
|
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
{issue.labelIds
|
||||||
|
.map((id) => labelById.get(id))
|
||||||
|
.filter((label): label is NonNullable<typeof label> => Boolean(label))
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="label-chip me-1"
|
||||||
|
style={{ fontSize: 10.5 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="label-dot"
|
||||||
|
style={{ background: label.color }}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="ms-auto">
|
||||||
|
<Avatar user={assignee} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { STATUS_META, STATUS_ORDER } from "@/lib/meta";
|
||||||
|
import type { Issue } from "@/lib/types";
|
||||||
|
import { useReferenceLookups } from "@/lib/store";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { StatusIcon } from "@/components/ui/Icons";
|
||||||
|
import { IssueRow } from "./IssueRow";
|
||||||
|
|
||||||
|
interface IssueListProps {
|
||||||
|
issues: Issue[];
|
||||||
|
loading?: boolean;
|
||||||
|
/** Campo por el que agrupar. Por defecto, estado. */
|
||||||
|
groupBy?: "status" | "project" | "none";
|
||||||
|
/** Texto alternativo para el estado vacío (p. ej. al filtrar). */
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabla de issues agrupada.
|
||||||
|
*
|
||||||
|
* Renderiza un encabezado pegajoso por grupo y delega el pintado de cada fila
|
||||||
|
* en `IssueRow`. Con `groupBy: "none"` emite una única tabla continua.
|
||||||
|
*/
|
||||||
|
export function IssueList({
|
||||||
|
issues,
|
||||||
|
loading,
|
||||||
|
groupBy = "status",
|
||||||
|
emptyTitle = "No hay issues aquí",
|
||||||
|
emptyDescription = "Crea un nuevo issue para empezar.",
|
||||||
|
}: IssueListProps) {
|
||||||
|
const { projectById, userById, labelById } = useReferenceLookups();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <IssueListSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="bx-check-circle"
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tabla simple con las filas dadas. */
|
||||||
|
const table = (groupIssues: Issue[]) => (
|
||||||
|
<table className="table table-borderless issue-table mb-0">
|
||||||
|
<tbody>
|
||||||
|
{groupIssues.map((issue) => (
|
||||||
|
<IssueRow
|
||||||
|
key={issue.id}
|
||||||
|
issue={issue}
|
||||||
|
assignee={issue.assigneeId ? userById.get(issue.assigneeId) : undefined}
|
||||||
|
labels={issue.labelIds
|
||||||
|
.map((id) => labelById.get(id))
|
||||||
|
.filter((label): label is NonNullable<typeof label> => Boolean(label))}
|
||||||
|
showProject={groupBy !== "project"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Encabezado de grupo pegajoso con icono y contador. */
|
||||||
|
const header = (icon: React.ReactNode, label: string, count: number) => (
|
||||||
|
<div className="group-header">
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
<span style={{ color: "var(--lp-text-faint)", fontWeight: 400 }}>{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupBy === "none") {
|
||||||
|
return table(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupBy === "status") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{STATUS_ORDER.map((status) => {
|
||||||
|
const groupIssues = issues.filter((issue) => issue.status === status);
|
||||||
|
if (groupIssues.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={status}>
|
||||||
|
{header(
|
||||||
|
<StatusIcon status={status} />,
|
||||||
|
STATUS_META[status].label,
|
||||||
|
groupIssues.length,
|
||||||
|
)}
|
||||||
|
{table(groupIssues)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrupar por proyecto, conservando el orden de aparición.
|
||||||
|
const projectIds = [...new Set(issues.map((issue) => issue.projectId))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{projectIds.map((projectId) => {
|
||||||
|
const groupIssues = issues.filter((issue) => issue.projectId === projectId);
|
||||||
|
const project = projectId ? projectById.get(projectId) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={projectId ?? "none"}>
|
||||||
|
{header(
|
||||||
|
<i className="bx bx-folder" style={{ color: "var(--inv-blue)" }} />,
|
||||||
|
project?.name ?? "Sin proyecto",
|
||||||
|
groupIssues.length,
|
||||||
|
)}
|
||||||
|
{table(groupIssues)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Esqueleto de carga con filas fantasma, mientras llegan los datos. */
|
||||||
|
function IssueListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div key={index} className="d-flex align-items-center gap-3 py-2">
|
||||||
|
<div className="skeleton" style={{ width: 14, height: 14, borderRadius: "50%" }} />
|
||||||
|
<div className="skeleton" style={{ width: 14, height: 14, borderRadius: "50%" }} />
|
||||||
|
<div className="skeleton" style={{ width: 64, height: 12 }} />
|
||||||
|
<div className="skeleton" style={{ width: `${30 + ((index * 7) % 40)}%`, height: 12 }} />
|
||||||
|
<div
|
||||||
|
className="skeleton ms-auto"
|
||||||
|
style={{ width: 22, height: 22, borderRadius: "50%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { Issue, Label, User } from "@/lib/types";
|
||||||
|
import { PRIORITY_META } from "@/lib/meta";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { PriorityIcon, StatusIcon, TypeIcon } from "@/components/ui/Icons";
|
||||||
|
import { LabelChip } from "@/components/ui/LabelChip";
|
||||||
|
|
||||||
|
interface IssueRowProps {
|
||||||
|
issue: Issue;
|
||||||
|
assignee?: User;
|
||||||
|
labels: Label[];
|
||||||
|
/** Columnas secundarias (proyecto, ciclo) ocultas en pantallas pequeñas. */
|
||||||
|
showProject?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fila de una tabla de issues. Al hacer clic navega al detalle. */
|
||||||
|
export function IssueRow({ issue, assignee, labels, showProject = true }: IssueRowProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className="issue-row"
|
||||||
|
onClick={() => router.push(`/issues/${issue.id}`)}
|
||||||
|
title={PRIORITY_META[issue.priority].label}
|
||||||
|
>
|
||||||
|
<td className="issue-cell" style={{ width: 34 }}>
|
||||||
|
<PriorityIcon priority={issue.priority} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="issue-cell" style={{ width: 34 }}>
|
||||||
|
<StatusIcon status={issue.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="issue-cell issue-title-cell">
|
||||||
|
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span className="issue-identifier">{issue.identifier}</span>
|
||||||
|
<span className="issue-title">{issue.title}</span>
|
||||||
|
{labels.map((label) => (
|
||||||
|
<LabelChip key={label.id} label={label} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{showProject && (
|
||||||
|
<td className="issue-cell issue-cell-secondary" style={{ width: 130 }}>
|
||||||
|
<span style={{ fontSize: 12.5, color: "var(--lp-text-faint)" }}>
|
||||||
|
{issue.type !== "task" && <TypeIcon type={issue.type} size={13} />}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<td className="issue-cell issue-cell-secondary" style={{ width: 110 }}>
|
||||||
|
<span className="issue-identifier">{issue.createdAt.slice(0, 10)}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="issue-cell" style={{ width: 44 }}>
|
||||||
|
<Avatar user={assignee} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Issue } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useReference } from "@/lib/store";
|
||||||
|
import { CommandPalette } from "./CommandPalette";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
/** Título de la sección, mostrado en el hero. */
|
||||||
|
title: string;
|
||||||
|
/** Bajada del hero. */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Acciones alineadas a la derecha del hero. */
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
icon?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estructura común de todas las páginas: sidebar colapsable, hero
|
||||||
|
* (sam-toolbar) y palette de comandos. Patrón SamShell de la plataforma.
|
||||||
|
*
|
||||||
|
* Protege la sesión: sin usuario redirige a /login.
|
||||||
|
*
|
||||||
|
* Carga la lista completa de issues una sola vez para alimentar los
|
||||||
|
* contadores del sidebar y el buscador de la palette. Las páginas que
|
||||||
|
* necesiten issues con filtros propios hacen su consulta aparte.
|
||||||
|
*/
|
||||||
|
export function AppShell({ title, subtitle, actions, icon = "bx-briefcase", children }: AppShellProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, loading: authLoading, logout } = useAuth();
|
||||||
|
const { projects } = useReference();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
// Cargar issues solo cuando hay sesión (los endpoints exigen API key y
|
||||||
|
// los datos no tienen sentido anónimos).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void api.listIssues().then((result) => {
|
||||||
|
if (!cancelled) setIssues(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Atajo global Cmd/Ctrl + K para abrir la palette.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
setPaletteOpen((openState) => !openState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cerrar la palette al navegar.
|
||||||
|
useEffect(() => {
|
||||||
|
setPaletteOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const openIssues = issues.filter((i) => i.status !== "done" && i.status !== "canceled");
|
||||||
|
return {
|
||||||
|
inbox: user ? openIssues.filter((i) => i.assigneeId === user.id).length : 0,
|
||||||
|
issues: openIssues.length,
|
||||||
|
board: openIssues.length,
|
||||||
|
cycles: openIssues.filter((i) => i.cycleId).length,
|
||||||
|
projects: projects.length,
|
||||||
|
};
|
||||||
|
}, [issues, projects, user]);
|
||||||
|
|
||||||
|
const closePalette = useCallback(() => setPaletteOpen(false), []);
|
||||||
|
|
||||||
|
const paletteIssues = useMemo(
|
||||||
|
() => issues.map((issue) => ({ id: issue.id, identifier: issue.identifier, title: issue.title })),
|
||||||
|
[issues],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mientras se restaura la sesión, nada que renderizar todavía.
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center justify-content-center vh-100">
|
||||||
|
<div className="spinner-border" role="status" aria-label="Cargando" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin sesión: volver al login.
|
||||||
|
if (!user) {
|
||||||
|
router.replace("/login");
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center justify-content-center vh-100">
|
||||||
|
<div className="spinner-border" role="status" aria-label="Redirigiendo" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sam-shell${open ? " open" : ""}`}>
|
||||||
|
<Sidebar counts={counts} projects={projects.map((p) => ({ id: p.id, name: p.name }))} open={open} />
|
||||||
|
|
||||||
|
<div className="home-section">
|
||||||
|
<div className="page-pad pb-0">
|
||||||
|
<div className="sam-toolbar d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<div className="sam-hero-main">
|
||||||
|
<span className="sam-hero-icon">
|
||||||
|
<i className={`bx ${icon}`} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="sam-hero-title text-truncate">{title}</div>
|
||||||
|
{subtitle && <div className="sam-hero-sub text-truncate">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ms-auto d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm d-flex align-items-center gap-2"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.15)",
|
||||||
|
border: "none",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bx bx-search" />
|
||||||
|
<span style={{ opacity: 0.8 }}>Buscar…</span>
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
border: "1px solid rgba(255,255,255,0.35)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "0 5px",
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm d-flex align-items-center gap-1"
|
||||||
|
onClick={() => router.push("/issues?new=1")}
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--primary)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bx bx-plus" />
|
||||||
|
Nuevo issue
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-pad pt-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón de sesión en la esquina, visible con el sidebar colapsado */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
title={`Cerrar sesión de ${user.name}`}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 16,
|
||||||
|
left: open ? "calc(var(--sidebar-expanded) - 46px)" : "16px",
|
||||||
|
zIndex: 1040,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "none",
|
||||||
|
background: "#fff",
|
||||||
|
color: "var(--primary)",
|
||||||
|
boxShadow: "var(--card-shadow)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "left var(--transition)",
|
||||||
|
}}
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
|
>
|
||||||
|
<i className="bx bx-log-out" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CommandPalette open={paletteOpen} onClose={closePalette} issues={paletteIssues} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Issues recientes para navegar directamente a su detalle. */
|
||||||
|
issues: Array<{ id: string; identifier: string; title: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Palette de comandos al estilo Linear.
|
||||||
|
*
|
||||||
|
* Se abre con Cmd/Ctrl + K (el atajo se registra en el layout raíz). Filtra
|
||||||
|
* por texto y navega con las flechas, ejecutando la acción con Enter.
|
||||||
|
*/
|
||||||
|
export function CommandPalette({ open, onClose, issues }: CommandPaletteProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
|
// Limpiar el estado al abrir/cerrar y enfocar el input.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQuery("");
|
||||||
|
setSelected(0);
|
||||||
|
// El input solo existe cuando `open` es true.
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const items = useMemo<PaletteItem[]>(() => {
|
||||||
|
const navigation: PaletteItem[] = [
|
||||||
|
{ id: "nav-inbox", icon: "bx-inbox", label: "Ir a Bandeja", hint: "G", run: () => router.push("/inbox") },
|
||||||
|
{ id: "nav-issues", icon: "bx-list-ul", label: "Ir a Issues", hint: "I", run: () => router.push("/issues") },
|
||||||
|
{ id: "nav-board", icon: "bx-columns", label: "Ir a Tablero", hint: "B", run: () => router.push("/board") },
|
||||||
|
{ id: "nav-cycles", icon: "bx-reset", label: "Ir a Ciclos", hint: "C", run: () => router.push("/cycles") },
|
||||||
|
{ id: "nav-projects", icon: "bx-folder", label: "Ir a Proyectos", hint: "P", run: () => router.push("/projects") },
|
||||||
|
{
|
||||||
|
id: "new-issue",
|
||||||
|
icon: "bx-plus-circle",
|
||||||
|
label: "Crear nuevo issue",
|
||||||
|
hint: "N",
|
||||||
|
run: () => router.push("/issues?new=1"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issueItems: PaletteItem[] = issues.map((issue) => ({
|
||||||
|
id: `issue-${issue.id}`,
|
||||||
|
icon: "bx-radio-circle",
|
||||||
|
label: issue.title,
|
||||||
|
hint: issue.identifier,
|
||||||
|
run: () => router.push(`/issues/${issue.id}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const all = [...navigation, ...issueItems];
|
||||||
|
if (!query.trim()) return all;
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return all.filter(
|
||||||
|
(item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [issues, query, router]);
|
||||||
|
|
||||||
|
// Reiniciar la selección cuando cambia el resultado del filtro.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Mantener el elemento seleccionado visible en el scroll.
|
||||||
|
useEffect(() => {
|
||||||
|
const node = listRef.current?.children[selected] as HTMLElement | undefined;
|
||||||
|
node?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
function handleKeyDown(event: React.KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((current) => (current + 1) % Math.max(items.length, 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((current) => (current - 1 + items.length) % Math.max(items.length, 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = items[selected];
|
||||||
|
if (item) {
|
||||||
|
onClose();
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="palette-backdrop"
|
||||||
|
onClick={(event) => {
|
||||||
|
// Cerrar al pulsar fuera del panel.
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="palette" role="dialog" aria-modal="true" aria-label="Paleta de comandos">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="palette-input"
|
||||||
|
placeholder="Buscar un issue o escribir un comando…"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul className="palette-list" ref={listRef}>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<li className="palette-item" style={{ cursor: "default", color: "var(--lp-text-faint)" }}>
|
||||||
|
<i className="bx bx-search" />
|
||||||
|
Sin resultados para «{query}»
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`palette-item${index === selected ? " selected" : ""}`}
|
||||||
|
onMouseEnter={() => setSelected(index)}
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
item.run();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`bx ${item.icon}`} />
|
||||||
|
<span className="text-truncate">{item.label}</span>
|
||||||
|
{item.hint && (
|
||||||
|
<span className="ms-auto" style={{ fontSize: 11.5, color: "var(--lp-text-faint)" }}>
|
||||||
|
{item.hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
/** Contadores de issues por sección, para mostrar junto a cada enlace. */
|
||||||
|
counts: {
|
||||||
|
inbox: number;
|
||||||
|
issues: number;
|
||||||
|
board: number;
|
||||||
|
cycles: number;
|
||||||
|
projects: number;
|
||||||
|
};
|
||||||
|
projects: Array<{ id: string; name: string }>;
|
||||||
|
/** Controla el estado abierto/colapsado del shell. */
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navegación lateral, patrón SamShell de la plataforma: sidebar #00285D
|
||||||
|
* colapsable (78px <-> 250px) con iconos boxicons y tooltip al colapsar.
|
||||||
|
*
|
||||||
|
* El enlace activo se determina con `usePathname`, sin estado propio.
|
||||||
|
*/
|
||||||
|
export function Sidebar({ counts, projects, open }: SidebarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const mainLinks = [
|
||||||
|
{ href: "/inbox", icon: "bx-inbox", label: "Bandeja", count: counts.inbox },
|
||||||
|
{ href: "/issues", icon: "bx-list-ul", label: "Issues", count: counts.issues },
|
||||||
|
{ href: "/board", icon: "bx-columns", label: "Tablero", count: counts.board },
|
||||||
|
{ href: "/cycles", icon: "bx-reset", label: "Ciclos", count: counts.cycles },
|
||||||
|
{ href: "/projects", icon: "bx-folder", label: "Proyectos", count: counts.projects },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
pathname === href || (href !== "/" && pathname.startsWith(`${href}/`));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sam-sidebar">
|
||||||
|
<div className="logo-details">
|
||||||
|
<span className="logo_name">
|
||||||
|
<i className="bx bx-briefcase" style={{ fontSize: "1.3rem" }} />
|
||||||
|
<span className="brand-text">Proyectos</span>
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
className={`bx ${open ? "bx-menu-alt-right" : "bx-menu"}`}
|
||||||
|
id="btn"
|
||||||
|
role="button"
|
||||||
|
aria-label="Alternar menú"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="nav-list">
|
||||||
|
{mainLinks.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link href={link.href} className={isActive(link.href) ? "active" : ""}>
|
||||||
|
<i className={`bx ${link.icon}`} />
|
||||||
|
<span className="links_name">{link.label}</span>
|
||||||
|
{link.count > 0 && <span className="nav-badge">{link.count}</span>}
|
||||||
|
</Link>
|
||||||
|
<span className="tooltip">{link.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<>
|
||||||
|
<li style={{ margin: "14px 12px 4px" }}>
|
||||||
|
<span
|
||||||
|
className="links_name"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "rgba(219,228,243,0.55)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Proyectos
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{projects.map((project) => (
|
||||||
|
<li key={project.id}>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className={isActive(`/projects/${project.id}`) ? "active" : ""}
|
||||||
|
>
|
||||||
|
<i className="bx bx-folder" />
|
||||||
|
<span className="links_name text-truncate">{project.name}</span>
|
||||||
|
</Link>
|
||||||
|
<span className="tooltip">{project.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { User } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
user: User | null | undefined;
|
||||||
|
size?: "sm" | "lg";
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avatar circular con iniciales. Muestra un placeholder si no hay asignado. */
|
||||||
|
export function Avatar({ user, size = "sm", title }: AvatarProps) {
|
||||||
|
const className = `avatar${size === "lg" ? " avatar-lg" : ""}`;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<span className={`${className} avatar-unassigned`} title={title ?? "Sin asignar"} aria-label="Sin asignar">
|
||||||
|
<i className="bx bx-user" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
style={{ background: user.color }}
|
||||||
|
title={title ?? user.name}
|
||||||
|
aria-label={user.name}
|
||||||
|
>
|
||||||
|
{user.initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estado vacío centrado para listas sin resultados. */
|
||||||
|
export function EmptyState({ icon = "bx-inbox", title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">
|
||||||
|
<i className={`bx ${icon}`} />
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "var(--lp-text)", fontWeight: 600, marginBottom: 4 }}>{title}</div>
|
||||||
|
{description && <div style={{ fontSize: 13 }}>{description}</div>}
|
||||||
|
{action && <div className="mt-3">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { PRIORITY_META, STATUS_META, TYPE_META } from "@/lib/meta";
|
||||||
|
import type { IssuePriority, IssueStatus, IssueType } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iconografía con boxicons, igual que sam-portal y SistemaCalidad.
|
||||||
|
* Cada icono se compone con `bx bx-<nombre>`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface StatusIconProps {
|
||||||
|
status: IssueStatus;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icono de estado del issue. */
|
||||||
|
export function StatusIcon({ status, size = 16 }: StatusIconProps) {
|
||||||
|
const meta = STATUS_META[status];
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
className={`bx ${meta.icon}`}
|
||||||
|
style={{ color: meta.color, fontSize: size }}
|
||||||
|
title={meta.label}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriorityIconProps {
|
||||||
|
priority: IssuePriority;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icono de prioridad. */
|
||||||
|
export function PriorityIcon({ priority, size = 16 }: PriorityIconProps) {
|
||||||
|
const meta = PRIORITY_META[priority];
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
className={`bx ${meta.icon}`}
|
||||||
|
style={{ color: meta.color, fontSize: size }}
|
||||||
|
title={meta.label}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeIconProps {
|
||||||
|
type: IssueType;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icono de tipo de issue: bug, feature, tarea o chore. */
|
||||||
|
export function TypeIcon({ type, size = 16 }: TypeIconProps) {
|
||||||
|
const meta = TYPE_META[type];
|
||||||
|
return (
|
||||||
|
<i
|
||||||
|
className={`bx ${meta.icon}`}
|
||||||
|
style={{ color: meta.color, fontSize: size }}
|
||||||
|
title={meta.label}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Label } from "@/lib/types";
|
||||||
|
|
||||||
|
interface LabelChipProps {
|
||||||
|
label: Label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etiqueta pequeña con punto de color, estilo Linear. */
|
||||||
|
export function LabelChip({ label }: LabelChipProps) {
|
||||||
|
return (
|
||||||
|
<span className="label-chip">
|
||||||
|
<span className="label-dot" style={{ background: label.color }} />
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
interface ProgressBarProps {
|
||||||
|
value: number;
|
||||||
|
/** Color de la barra. Por defecto el acento de la app. */
|
||||||
|
color?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Barra de progreso fina reutilizable. */
|
||||||
|
export function ProgressBar({ value, color, label }: ProgressBarProps) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && (
|
||||||
|
<div className="d-flex justify-content-between mb-1" style={{ fontSize: 12 }}>
|
||||||
|
<span style={{ color: "var(--lp-text-faint)" }}>{label}</span>
|
||||||
|
<span style={{ color: "var(--lp-text-muted)" }}>{clamped}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="progress-lp">
|
||||||
|
<div
|
||||||
|
className="progress-lp-bar"
|
||||||
|
style={{ width: `${clamped}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Issue, IssueFilters } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga issues con los filtros dados y expone un `refresh` para volver a
|
||||||
|
* consultar tras mutaciones locales (crear issue, cambiar estado, etc.).
|
||||||
|
*
|
||||||
|
* `refreshKey` permite forzar una recarga desde el exterior sin recrear el
|
||||||
|
* objeto de filtros.
|
||||||
|
*/
|
||||||
|
export function useIssues(filters?: IssueFilters, refreshKey = 0) {
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtersKey = JSON.stringify(filters ?? {});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.listIssues(filters ? JSON.parse(filtersKey) : undefined);
|
||||||
|
setIssues(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Error cargando issues");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filtersKey, filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load, refreshKey]);
|
||||||
|
|
||||||
|
return { issues, loading, error, refresh: load };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
Comment,
|
||||||
|
Cycle,
|
||||||
|
ID,
|
||||||
|
Issue,
|
||||||
|
IssueFilters,
|
||||||
|
IssueInput,
|
||||||
|
Label,
|
||||||
|
Project,
|
||||||
|
User,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrato del backend de SAM para el módulo de Proyectos.
|
||||||
|
*
|
||||||
|
* La app solo habla contra esta interfaz. La implementación concreta se
|
||||||
|
* elige en `src/lib/api/index.ts`: hoy hay una mock en memoria, y cuando
|
||||||
|
* SAM exponga los endpoints reales bastará con añadir `HttpProyectosApi`.
|
||||||
|
*/
|
||||||
|
export interface ProyectosApi {
|
||||||
|
// Sesión
|
||||||
|
/** Valida credenciales contra sige_sam_v3.usuario y devuelve el miembro. */
|
||||||
|
login(idUsuario: string, password: string): Promise<User>;
|
||||||
|
|
||||||
|
// Datos de referencia
|
||||||
|
listUsers(): Promise<User[]>;
|
||||||
|
listLabels(): Promise<Label[]>;
|
||||||
|
listProjects(): Promise<Project[]>;
|
||||||
|
listCycles(): Promise<Cycle[]>;
|
||||||
|
|
||||||
|
// Issues
|
||||||
|
listIssues(filters?: IssueFilters): Promise<Issue[]>;
|
||||||
|
getIssue(id: ID): Promise<Issue | null>;
|
||||||
|
createIssue(input: IssueInput): Promise<Issue>;
|
||||||
|
updateIssue(id: ID, patch: Partial<IssueInput>): Promise<Issue>;
|
||||||
|
|
||||||
|
// Comentarios
|
||||||
|
listComments(issueId: ID): Promise<Comment[]>;
|
||||||
|
/** `authorId` es el idUsuario del usuario en sesión. */
|
||||||
|
addComment(issueId: ID, body: string, authorId: ID): Promise<Comment>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type {
|
||||||
|
Comment,
|
||||||
|
Cycle,
|
||||||
|
ID,
|
||||||
|
Issue,
|
||||||
|
IssueFilters,
|
||||||
|
IssueInput,
|
||||||
|
Label,
|
||||||
|
Project,
|
||||||
|
User,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import type { ProyectosApi } from "./client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementación HTTP del contrato `ProyectosApi`, contra `SAM.API`.
|
||||||
|
*
|
||||||
|
* Endpoints (módulo `ProyectosController`, schema `proyectos.*`):
|
||||||
|
* GET /api/proyectos/members
|
||||||
|
* GET /api/proyectos/labels
|
||||||
|
* GET /api/proyectos/projects
|
||||||
|
* GET /api/proyectos/cycles
|
||||||
|
* GET /api/proyectos/issues?status=&priority=&assigneeId=&...
|
||||||
|
* GET /api/proyectos/issues/{id}
|
||||||
|
* POST /api/proyectos/issues
|
||||||
|
* PATCH /api/proyectos/issues/{id}
|
||||||
|
* GET /api/proyectos/issues/{id}/comments
|
||||||
|
* POST /api/proyectos/issues/{id}/comments
|
||||||
|
*
|
||||||
|
* Autenticación: NUNCA se manda la API key desde el navegador. Las peticiones
|
||||||
|
* van al proxy de mismo origen (`src/app/api/proyectos/[...path]/route.ts`),
|
||||||
|
* que añade `X-Api-Key` en el servidor usando `SAM_API_KEY` (sin prefijo
|
||||||
|
* NEXT_PUBLIC_, por eso no se empaqueta en el bundle JS).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mismo origen: el proxy del servidor se encarga de hablar con SAM.API.
|
||||||
|
const BASE_URL = "";
|
||||||
|
|
||||||
|
/** Error de la API con el código HTTP y el mensaje devuelto por el backend. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
// La app cliente siempre quiere datos frescos.
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// El backend devuelve { message } en los errores.
|
||||||
|
const body = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
throw new ApiError(response.status, body?.message ?? `Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content: no hay cuerpo que parsear.
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializa un filtro de array repitiendo la clave: ?a=1&a=2 */
|
||||||
|
function buildQuery(filters?: IssueFilters): string {
|
||||||
|
if (!filters) return "";
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const append = (key: string, values?: string[]) => {
|
||||||
|
for (const value of values ?? []) params.append(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
append("status", filters.status);
|
||||||
|
append("assigneeId", filters.assigneeId);
|
||||||
|
append("projectId", filters.projectId);
|
||||||
|
append("cycleId", filters.cycleId);
|
||||||
|
append("labelIds", filters.labelIds);
|
||||||
|
|
||||||
|
// El backend numera la prioridad como int.
|
||||||
|
for (const value of filters.priority ?? []) params.append("priority", String(value));
|
||||||
|
|
||||||
|
// El backend llama `q` a la búsqueda de texto.
|
||||||
|
if (filters.query?.trim()) params.append("q", filters.query.trim());
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpApi: ProyectosApi = {
|
||||||
|
async login(idUsuario: string, password: string): Promise<User> {
|
||||||
|
return request<User>("/api/proyectos/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ idUsuario, password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// El frontend llama a esto "users"; el backend lo expone como "members".
|
||||||
|
listUsers: () => request<User[]>("/api/proyectos/members"),
|
||||||
|
|
||||||
|
listLabels: () => request<Label[]>("/api/proyectos/labels"),
|
||||||
|
|
||||||
|
listProjects: () => request<Project[]>("/api/proyectos/projects"),
|
||||||
|
|
||||||
|
listCycles: () => request<Cycle[]>("/api/proyectos/cycles"),
|
||||||
|
|
||||||
|
listIssues: (filters) => request<Issue[]>(`/api/proyectos/issues${buildQuery(filters)}`),
|
||||||
|
|
||||||
|
async getIssue(id: ID): Promise<Issue | null> {
|
||||||
|
try {
|
||||||
|
return await request<Issue>(`/api/proyectos/issues/${id}`);
|
||||||
|
} catch (error) {
|
||||||
|
// 404 = no existe; el contrato lo trata como null, no como error.
|
||||||
|
if (error instanceof ApiError && error.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createIssue: (input: IssueInput) =>
|
||||||
|
request<Issue>("/api/proyectos/issues", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateIssue: (id: ID, patch: Partial<IssueInput>) =>
|
||||||
|
request<Issue>(`/api/proyectos/issues/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listComments: (issueId: ID) => request<Comment[]>(`/api/proyectos/issues/${issueId}/comments`),
|
||||||
|
|
||||||
|
addComment: (issueId: ID, body: string, authorId: ID) =>
|
||||||
|
request<Comment>(`/api/proyectos/issues/${issueId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body, authorId }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ProyectosApi } from "./client";
|
||||||
|
import { httpApi } from "./http";
|
||||||
|
import { mockApi } from "./mock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punto único de acceso al backend desde cualquier componente.
|
||||||
|
*
|
||||||
|
* Selección automática:
|
||||||
|
* - Por defecto se usa la API real de SAM, vía el proxy de mismo origen
|
||||||
|
* (`src/app/api/proyectos/[...path]/route.ts`), que añade la API key en
|
||||||
|
* el servidor. El navegador nunca ve la clave.
|
||||||
|
* - Con `NEXT_PUBLIC_USE_MOCK=true` se usa la implementación mock en
|
||||||
|
* memoria, para desarrollar la UI sin backend levantado.
|
||||||
|
*
|
||||||
|
* La URL de SAM.API (`SAM_API_URL`) y su clave (`SAM_API_KEY`) se leen solo
|
||||||
|
* en el servidor: ninguna lleva prefijo NEXT_PUBLIC_, así que no se
|
||||||
|
* empaquetan en el JavaScript del cliente.
|
||||||
|
*/
|
||||||
|
export const api: ProyectosApi =
|
||||||
|
process.env.NEXT_PUBLIC_USE_MOCK === "true" ? mockApi : httpApi;
|
||||||
|
|
||||||
|
export type { ProyectosApi } from "./client";
|
||||||
|
export { ApiError } from "./http";
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type {
|
||||||
|
Comment,
|
||||||
|
Cycle,
|
||||||
|
ID,
|
||||||
|
Issue,
|
||||||
|
IssueFilters,
|
||||||
|
IssueInput,
|
||||||
|
Label,
|
||||||
|
Project,
|
||||||
|
User,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import * as seed from "@/lib/seed";
|
||||||
|
import type { ProyectosApi } from "./client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementación en memoria del contrato `ProyectosApi`.
|
||||||
|
*
|
||||||
|
* Sustituye a la API real mientras el backend de SAM no tenga el módulo de
|
||||||
|
* proyectos. Muta una copia local de los datos de `src/lib/seed.ts`, así que
|
||||||
|
* los cambios se mantienen durante la sesión pero no se persisten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
issues: structuredClone(seed.issues),
|
||||||
|
comments: structuredClone(seed.comments),
|
||||||
|
projects: structuredClone(seed.projects),
|
||||||
|
cycles: structuredClone(seed.cycles),
|
||||||
|
users: structuredClone(seed.users),
|
||||||
|
labels: structuredClone(seed.labels),
|
||||||
|
seq: seed.issues.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Simula latencia de red para que los estados de carga sean visibles. */
|
||||||
|
const delay = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function nextId(prefix: string): ID {
|
||||||
|
state.seq += 1;
|
||||||
|
return `${prefix}${state.seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdentifier(project: Project | undefined, seq: number): string {
|
||||||
|
const key = project?.key ?? "GEN";
|
||||||
|
return `${key}-${seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilters(issue: Issue, filters?: IssueFilters): boolean {
|
||||||
|
if (!filters) return true;
|
||||||
|
|
||||||
|
if (filters.status?.length && !filters.status.includes(issue.status)) return false;
|
||||||
|
if (filters.priority?.length && !filters.priority.includes(issue.priority)) return false;
|
||||||
|
if (filters.assigneeId?.length && !filters.assigneeId.includes(issue.assigneeId ?? ""))
|
||||||
|
return false;
|
||||||
|
if (filters.projectId?.length && !filters.projectId.includes(issue.projectId ?? "")) return false;
|
||||||
|
if (filters.cycleId?.length && !filters.cycleId.includes(issue.cycleId ?? "")) return false;
|
||||||
|
if (filters.labelIds?.length && !filters.labelIds.some((id) => issue.labelIds.includes(id)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (filters.query?.trim()) {
|
||||||
|
const q = filters.query.trim().toLowerCase();
|
||||||
|
const haystack = `${issue.identifier} ${issue.title} ${issue.description}`.toLowerCase();
|
||||||
|
if (!haystack.includes(q)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockApi: ProyectosApi = {
|
||||||
|
/**
|
||||||
|
* Login simulado: acepta el idUsuario 14399848 con cualquier clave, y
|
||||||
|
* también los miembros del seed (u1..u4). Solo sirve para desarrollar
|
||||||
|
* la UI sin backend; la validación real ocurre en SAM.API.
|
||||||
|
*/
|
||||||
|
async login(idUsuario: string, password: string): Promise<User> {
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
const id = idUsuario.trim();
|
||||||
|
if (!id || !password) throw new Error("Faltan credenciales.");
|
||||||
|
|
||||||
|
if (id === "14399848") {
|
||||||
|
return {
|
||||||
|
id: "14399848",
|
||||||
|
name: "Juan Pérez",
|
||||||
|
email: "juan@ichn.local",
|
||||||
|
initials: "JP",
|
||||||
|
color: "#015fc9",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = state.users.find((user) => user.id === id);
|
||||||
|
if (!found) throw new Error("Credenciales inválidas.");
|
||||||
|
return found;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listUsers(): Promise<User[]> {
|
||||||
|
await delay(40);
|
||||||
|
return state.users;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listLabels(): Promise<Label[]> {
|
||||||
|
await delay(40);
|
||||||
|
return state.labels;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listProjects(): Promise<Project[]> {
|
||||||
|
await delay(60);
|
||||||
|
return state.projects;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listCycles(): Promise<Cycle[]> {
|
||||||
|
await delay(60);
|
||||||
|
return state.cycles;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listIssues(filters?: IssueFilters): Promise<Issue[]> {
|
||||||
|
await delay(140);
|
||||||
|
return state.issues.filter((issue) => matchesFilters(issue, filters));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getIssue(id: ID): Promise<Issue | null> {
|
||||||
|
await delay(80);
|
||||||
|
return state.issues.find((issue) => issue.id === id) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createIssue(input: IssueInput): Promise<Issue> {
|
||||||
|
await delay(120);
|
||||||
|
const project = state.projects.find((p) => p.id === input.projectId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const issue: Issue = {
|
||||||
|
id: nextId("i"),
|
||||||
|
identifier: buildIdentifier(project, state.seq + 1),
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? "",
|
||||||
|
status: input.status ?? "todo",
|
||||||
|
priority: input.priority ?? 0,
|
||||||
|
type: input.type ?? "task",
|
||||||
|
assigneeId: input.assigneeId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
cycleId: input.cycleId ?? null,
|
||||||
|
labelIds: input.labelIds ?? [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.issues.unshift(issue);
|
||||||
|
return issue;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateIssue(id: ID, patch: Partial<IssueInput>): Promise<Issue> {
|
||||||
|
await delay(120);
|
||||||
|
const issue = state.issues.find((i) => i.id === id);
|
||||||
|
if (!issue) throw new Error(`Issue ${id} no encontrado`);
|
||||||
|
|
||||||
|
Object.assign(issue, patch, { updatedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
// Mantener `completedAt` coherente con el estado final.
|
||||||
|
const isFinished = issue.status === "done" || issue.status === "canceled";
|
||||||
|
issue.completedAt = isFinished ? (issue.completedAt ?? new Date().toISOString()) : null;
|
||||||
|
|
||||||
|
return issue;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listComments(issueId: ID): Promise<Comment[]> {
|
||||||
|
await delay(60);
|
||||||
|
return state.comments.filter((c) => c.issueId === issueId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async addComment(issueId: ID, body: string, authorId: ID): Promise<Comment> {
|
||||||
|
await delay(100);
|
||||||
|
const comment: Comment = {
|
||||||
|
issueId,
|
||||||
|
id: nextId("cm"),
|
||||||
|
authorId,
|
||||||
|
body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
state.comments.push(comment);
|
||||||
|
return comment;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { User } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sesión del módulo.
|
||||||
|
*
|
||||||
|
* El login valida contra sige_sam_v3.usuario en el backend
|
||||||
|
* (POST /api/proyectos/auth/login). El miembro resultante se guarda en
|
||||||
|
* localStorage y se expone por contexto, para que toda la app sepa quién
|
||||||
|
* está conectado sin volver a preguntar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "proyectos.session";
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: User | null;
|
||||||
|
/** true mientras se restaura la sesión guardada. */
|
||||||
|
loading: boolean;
|
||||||
|
login: (idUsuario: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Restaurar sesión al montar (solo en el navegador).
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const session = JSON.parse(raw) as Session;
|
||||||
|
if (session?.user?.id) setUser(session.user);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Sesión corrupta: se ignora y queda como desconectado.
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (idUsuario: string, password: string) => {
|
||||||
|
const member = await api.login(idUsuario.trim(), password);
|
||||||
|
const session: Session = { user: member };
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||||
|
setUser(member);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
setUser(null);
|
||||||
|
window.location.href = "/login";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({ user, loading, login, logout }),
|
||||||
|
[user, loading, login, logout],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/** Helpers de formato compartidos por toda la app. */
|
||||||
|
|
||||||
|
const MESES = [
|
||||||
|
"ene",
|
||||||
|
"feb",
|
||||||
|
"mar",
|
||||||
|
"abr",
|
||||||
|
"may",
|
||||||
|
"jun",
|
||||||
|
"jul",
|
||||||
|
"ago",
|
||||||
|
"sep",
|
||||||
|
"oct",
|
||||||
|
"nov",
|
||||||
|
"dic",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Formatea una fecha ISO como "12 oct" o "12 oct 2025" si no es el año en curso. */
|
||||||
|
export function formatShortDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = MESES[date.getMonth()];
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return year === currentYear ? `${day} ${month}` : `${day} ${month} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatea una fecha como "12 de octubre de 2026". */
|
||||||
|
export function formatLongDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
|
||||||
|
return date.toLocaleDateString("es-CL", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiempo relativo en español: "hace 3 días", "hace 2 horas". */
|
||||||
|
export function formatRelative(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
const minutes = Math.round(diffMs / 60000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "ahora";
|
||||||
|
if (minutes < 60) return `hace ${minutes} min`;
|
||||||
|
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `hace ${hours} h`;
|
||||||
|
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
if (days < 30) return `hace ${days} días`;
|
||||||
|
|
||||||
|
return formatShortDate(iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Porcentaje de progreso redondeado, evitando división por cero. */
|
||||||
|
export function percent(part: number, total: number): number {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round((part / total) * 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { IssueStatus, IssuePriority, IssueType } from "@/lib/types";
|
||||||
|
|
||||||
|
/** Definiciones canónicas para pintar badges y iconos de forma consistente. */
|
||||||
|
|
||||||
|
export const STATUS_META: Record<
|
||||||
|
IssueStatus,
|
||||||
|
{ label: string; color: string; icon: string; group: string }
|
||||||
|
> = {
|
||||||
|
backlog: { label: "Backlog", color: "#6c788b", icon: "bx-radio-circle", group: "Sin iniciar" },
|
||||||
|
todo: { label: "Por hacer", color: "#6c788b", icon: "bx-radio-circle", group: "Sin iniciar" },
|
||||||
|
in_progress: { label: "En curso", color: "#d19b3d", icon: "bx-error-circle", group: "En curso" },
|
||||||
|
in_review: { label: "En revisión", color: "#8c6fd1", icon: "bx-time", group: "En curso" },
|
||||||
|
done: { label: "Completado", color: "#015fc9", icon: "bx-check-circle", group: "Completado" },
|
||||||
|
canceled: { label: "Cancelado", color: "#98a2b3", icon: "bx-x-circle", group: "Completado" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_ORDER: IssueStatus[] = [
|
||||||
|
"backlog",
|
||||||
|
"todo",
|
||||||
|
"in_progress",
|
||||||
|
"in_review",
|
||||||
|
"done",
|
||||||
|
"canceled",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRIORITY_META: Record<
|
||||||
|
IssuePriority,
|
||||||
|
{ label: string; color: string; icon: string; rank: number }
|
||||||
|
> = {
|
||||||
|
0: { label: "Sin prioridad", color: "#6c788b", icon: "bx-dots-horizontal-rounded", rank: 0 },
|
||||||
|
4: { label: "Urgente", color: "#d93025", icon: "bx-error", rank: 1 },
|
||||||
|
3: { label: "Alta", color: "#e8710a", icon: "bx-up-arrow-alt", rank: 2 },
|
||||||
|
2: { label: "Media", color: "#d19b3d", icon: "bx-equal", rank: 3 },
|
||||||
|
1: { label: "Baja", color: "#015fc9", icon: "bx-down-arrow-alt", rank: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TYPE_META: Record<IssueType, { label: string; icon: string; color: string }> = {
|
||||||
|
task: { label: "Tarea", icon: "bx-task", color: "#6c788b" },
|
||||||
|
bug: { label: "Bug", icon: "bx-bug", color: "#d93025" },
|
||||||
|
feature: { label: "Feature", icon: "bx-star", color: "#015fc9" },
|
||||||
|
chore: { label: "Chore", icon: "bx-cog", color: "#6c788b" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROJECT_STATUS_META: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; color: string; icon: string }
|
||||||
|
> = {
|
||||||
|
planned: { label: "Planificado", color: "#6c788b", icon: "bx-radio-circle" },
|
||||||
|
in_progress: { label: "En curso", color: "#d19b3d", icon: "bx-error-circle" },
|
||||||
|
paused: { label: "Pausado", color: "#e8710a", icon: "bx-pause-circle" },
|
||||||
|
completed: { label: "Completado", color: "#015fc9", icon: "bx-check-circle" },
|
||||||
|
canceled: { label: "Cancelado", color: "#98a2b3", icon: "bx-x-circle" },
|
||||||
|
};
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
import type { Comment, Cycle, Issue, Label, Project, User } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos de ejemplo usados por la capa mock.
|
||||||
|
*
|
||||||
|
* Mientras el backend de SAM no exponga los endpoints de proyectos, la app
|
||||||
|
* consume estos datos en memoria. La forma de cada objeto replica el contrato
|
||||||
|
* definido en src/lib/types.ts, de modo que sustituir mock por HTTP no obliga
|
||||||
|
* a tocar ningún componente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const users: User[] = [
|
||||||
|
{ id: "u1", name: "Juan Pérez", email: "juan@ichn.local", initials: "JP", color: "#5e6ad2" },
|
||||||
|
{ id: "u2", name: "María Rojas", email: "maria@ichn.local", initials: "MR", color: "#f2994a" },
|
||||||
|
{ id: "u3", name: "Diego Soto", email: "diego@ichn.local", initials: "DS", color: "#26b5ce" },
|
||||||
|
{ id: "u4", name: "Carla Muñoz", email: "carla@ichn.local", initials: "CM", color: "#bb87fc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const labels: Label[] = [
|
||||||
|
{ id: "l1", name: "frontend", color: "#5e6ad2" },
|
||||||
|
{ id: "l2", name: "backend", color: "#26b5ce" },
|
||||||
|
{ id: "l3", name: "diseño", color: "#bb87fc" },
|
||||||
|
{ id: "l4", name: "bug", color: "#fb2c36" },
|
||||||
|
{ id: "l5", name: "deuda técnica", color: "#f2994a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "Plataforma",
|
||||||
|
key: "PLAT",
|
||||||
|
description: "Núcleo de la plataforma: autenticación, usuarios y permisos.",
|
||||||
|
status: "in_progress",
|
||||||
|
leadId: "u1",
|
||||||
|
startDate: "2026-09-01",
|
||||||
|
targetDate: "2026-12-15",
|
||||||
|
createdAt: "2026-08-20T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
name: "Portal Alumnos",
|
||||||
|
key: "PORT",
|
||||||
|
description: "Portal web para alumnos: matrícula, pagos y notas.",
|
||||||
|
status: "planned",
|
||||||
|
leadId: "u2",
|
||||||
|
startDate: "2026-10-15",
|
||||||
|
targetDate: "2027-02-01",
|
||||||
|
createdAt: "2026-09-02T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p3",
|
||||||
|
name: "Helpdesk",
|
||||||
|
key: "HELP",
|
||||||
|
description: "Sistema de tickets y soporte interno.",
|
||||||
|
status: "completed",
|
||||||
|
leadId: "u3",
|
||||||
|
startDate: "2026-05-01",
|
||||||
|
targetDate: "2026-09-30",
|
||||||
|
createdAt: "2026-04-10T10:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const cycles: Cycle[] = [
|
||||||
|
{
|
||||||
|
id: "c3",
|
||||||
|
name: "Ciclo 3",
|
||||||
|
number: 3,
|
||||||
|
projectId: null,
|
||||||
|
startsAt: "2026-10-05",
|
||||||
|
endsAt: "2026-10-26",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c4",
|
||||||
|
name: "Ciclo 4",
|
||||||
|
number: 4,
|
||||||
|
projectId: null,
|
||||||
|
startsAt: "2026-10-27",
|
||||||
|
endsAt: "2026-11-16",
|
||||||
|
status: "upcoming",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c2",
|
||||||
|
name: "Ciclo 2",
|
||||||
|
number: 2,
|
||||||
|
projectId: null,
|
||||||
|
startsAt: "2026-09-14",
|
||||||
|
endsAt: "2026-10-04",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const issues: Issue[] = [
|
||||||
|
{
|
||||||
|
id: "i1",
|
||||||
|
identifier: "PLAT-42",
|
||||||
|
title: "Implementar login con JWT y refresh token",
|
||||||
|
description:
|
||||||
|
"El backend expone `POST /api/auth/login`. Falta integrar el guardado del token y el refresco automático antes de expirar.",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: 3,
|
||||||
|
type: "feature",
|
||||||
|
assigneeId: "u1",
|
||||||
|
projectId: "p1",
|
||||||
|
cycleId: "c3",
|
||||||
|
labelIds: ["l2"],
|
||||||
|
createdAt: "2026-10-01T09:15:00Z",
|
||||||
|
updatedAt: "2026-10-12T15:40:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i2",
|
||||||
|
identifier: "PLAT-43",
|
||||||
|
title: "Bug: el sidebar se cierra al navegar en móvil",
|
||||||
|
description: "Al pulsar un enlace del sidebar en pantallas pequeñas el menú no se colapsa.",
|
||||||
|
status: "todo",
|
||||||
|
priority: 4,
|
||||||
|
type: "bug",
|
||||||
|
assigneeId: "u2",
|
||||||
|
projectId: "p1",
|
||||||
|
cycleId: "c3",
|
||||||
|
labelIds: ["l1", "l4"],
|
||||||
|
createdAt: "2026-10-03T11:00:00Z",
|
||||||
|
updatedAt: "2026-10-10T08:20:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i3",
|
||||||
|
identifier: "PLAT-44",
|
||||||
|
title: "Diseñar sistema de permisos por rol",
|
||||||
|
description: "Definir roles sam, portal e it y cómo se mapean a los módulos.",
|
||||||
|
status: "in_review",
|
||||||
|
priority: 2,
|
||||||
|
type: "task",
|
||||||
|
assigneeId: "u4",
|
||||||
|
projectId: "p1",
|
||||||
|
cycleId: "c3",
|
||||||
|
labelIds: ["l3"],
|
||||||
|
createdAt: "2026-09-28T14:30:00Z",
|
||||||
|
updatedAt: "2026-10-11T17:05:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i4",
|
||||||
|
identifier: "PLAT-45",
|
||||||
|
title: "Añadir rate limiting al endpoint de login",
|
||||||
|
description: "El backend ya lo expone con `[EnableRateLimiting(\"login\")]`. Verificar el comportamiento desde el cliente.",
|
||||||
|
status: "done",
|
||||||
|
priority: 2,
|
||||||
|
type: "task",
|
||||||
|
assigneeId: "u3",
|
||||||
|
projectId: "p1",
|
||||||
|
cycleId: "c3",
|
||||||
|
labelIds: ["l2"],
|
||||||
|
createdAt: "2026-09-20T10:00:00Z",
|
||||||
|
updatedAt: "2026-10-08T12:00:00Z",
|
||||||
|
completedAt: "2026-10-08T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i5",
|
||||||
|
identifier: "PORT-7",
|
||||||
|
title: "Pantalla de matrícula en línea",
|
||||||
|
description: "Formulario multi-paso con validación RUT y selección de cursos.",
|
||||||
|
status: "backlog",
|
||||||
|
priority: 1,
|
||||||
|
type: "feature",
|
||||||
|
assigneeId: null,
|
||||||
|
projectId: "p2",
|
||||||
|
cycleId: null,
|
||||||
|
labelIds: ["l1", "l3"],
|
||||||
|
createdAt: "2026-10-06T09:00:00Z",
|
||||||
|
updatedAt: "2026-10-06T09:00:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i6",
|
||||||
|
identifier: "PORT-8",
|
||||||
|
title: "Integrar WebPay para pagos de arancel",
|
||||||
|
description: "Flujo de pago con redirección y confirmación por webhook.",
|
||||||
|
status: "todo",
|
||||||
|
priority: 4,
|
||||||
|
type: "feature",
|
||||||
|
assigneeId: "u1",
|
||||||
|
projectId: "p2",
|
||||||
|
cycleId: "c4",
|
||||||
|
labelIds: ["l2"],
|
||||||
|
createdAt: "2026-10-07T16:45:00Z",
|
||||||
|
updatedAt: "2026-10-09T10:10:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i7",
|
||||||
|
identifier: "HELP-12",
|
||||||
|
title: "Exportar tickets a CSV",
|
||||||
|
description: "El módulo de helpdesk ya está entregado; queda deuda técnica en la exportación.",
|
||||||
|
status: "canceled",
|
||||||
|
priority: 0,
|
||||||
|
type: "chore",
|
||||||
|
assigneeId: "u3",
|
||||||
|
projectId: "p3",
|
||||||
|
cycleId: "c2",
|
||||||
|
labelIds: ["l5"],
|
||||||
|
createdAt: "2026-08-15T13:20:00Z",
|
||||||
|
updatedAt: "2026-09-30T09:00:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i8",
|
||||||
|
identifier: "PLAT-46",
|
||||||
|
title: "Refactorizar el cliente HTTP compartido",
|
||||||
|
description: "Unificar fetch con reintentos y manejo de errores 401.",
|
||||||
|
status: "todo",
|
||||||
|
priority: 1,
|
||||||
|
type: "chore",
|
||||||
|
assigneeId: "u2",
|
||||||
|
projectId: "p1",
|
||||||
|
cycleId: "c4",
|
||||||
|
labelIds: ["l5"],
|
||||||
|
createdAt: "2026-10-09T08:00:00Z",
|
||||||
|
updatedAt: "2026-10-09T08:00:00Z",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const comments: Comment[] = [
|
||||||
|
{
|
||||||
|
id: "cm1",
|
||||||
|
issueId: "i1",
|
||||||
|
authorId: "u2",
|
||||||
|
body: "¿Vamos a usar httpOnly cookies o guardamos el token en localStorage?",
|
||||||
|
createdAt: "2026-10-11T10:30:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cm2",
|
||||||
|
issueId: "i1",
|
||||||
|
authorId: "u1",
|
||||||
|
body: "Preferiría httpOnly + refresh cookie, pero eso lo decide el backend de SAM.",
|
||||||
|
createdAt: "2026-10-11T11:05:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Cycle, Label, Project, User } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datos de referencia compartidos por toda la app (usuarios, etiquetas,
|
||||||
|
* proyectos y ciclos).
|
||||||
|
*
|
||||||
|
* Se cargan una sola vez en el layout raíz y se exponen por contexto, para
|
||||||
|
* que cada página pueda resolver nombres, colores e iniciales sin repetir
|
||||||
|
* llamadas a la API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ReferenceData {
|
||||||
|
users: User[];
|
||||||
|
labels: Label[];
|
||||||
|
projects: Project[];
|
||||||
|
cycles: Cycle[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReferenceContext = createContext<ReferenceData>({
|
||||||
|
users: [],
|
||||||
|
labels: [],
|
||||||
|
projects: [],
|
||||||
|
cycles: [],
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ReferenceProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [data, setData] = useState<Omit<ReferenceData, "loading">>({
|
||||||
|
users: [],
|
||||||
|
labels: [],
|
||||||
|
projects: [],
|
||||||
|
cycles: [],
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [users, labels, projects, cycles] = await Promise.all([
|
||||||
|
api.listUsers(),
|
||||||
|
api.listLabels(),
|
||||||
|
api.listProjects(),
|
||||||
|
api.listCycles(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setData({ users, labels, projects, cycles });
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ReferenceData>(() => ({ ...data, loading }), [data, loading]);
|
||||||
|
|
||||||
|
return <ReferenceContext.Provider value={value}>{children}</ReferenceContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accede a los datos de referencia ya cargados. */
|
||||||
|
export function useReference(): ReferenceData {
|
||||||
|
return useContext(ReferenceContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helpers de búsqueda por id, memorizados junto a los datos. */
|
||||||
|
export function useReferenceLookups() {
|
||||||
|
const { users, labels, projects, cycles } = useReference();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
userById: new Map(users.map((u) => [u.id, u])),
|
||||||
|
labelById: new Map(labels.map((l) => [l.id, l])),
|
||||||
|
projectById: new Map(projects.map((p) => [p.id, p])),
|
||||||
|
cycleById: new Map(cycles.map((c) => [c.id, c])),
|
||||||
|
}),
|
||||||
|
[users, labels, projects, cycles],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Modelo de dominio de "Proyectos" (estilo Linear).
|
||||||
|
*
|
||||||
|
* Estos tipos son el contrato entre el frontend y el backend de SAM.
|
||||||
|
* Cuando la API real exponga estos recursos, basta con implementar el
|
||||||
|
* cliente HTTP con esta misma forma (ver src/lib/api/index.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ID = string;
|
||||||
|
|
||||||
|
export type IssueStatus =
|
||||||
|
| "backlog"
|
||||||
|
| "todo"
|
||||||
|
| "in_progress"
|
||||||
|
| "in_review"
|
||||||
|
| "done"
|
||||||
|
| "canceled";
|
||||||
|
|
||||||
|
export type IssuePriority = 0 | 1 | 2 | 3 | 4; // 0 = sin prioridad .. 4 = urgente
|
||||||
|
|
||||||
|
export type IssueType = "task" | "bug" | "feature" | "chore";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
initials: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Label {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
/** Identificador corto usado como prefijo de los issues, ej. "PLAT". */
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
status: "planned" | "in_progress" | "paused" | "completed" | "canceled";
|
||||||
|
leadId: ID | null;
|
||||||
|
startDate: string | null;
|
||||||
|
targetDate: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cycle {
|
||||||
|
id: ID;
|
||||||
|
name: string;
|
||||||
|
number: number;
|
||||||
|
projectId: ID | null;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
status: "upcoming" | "active" | "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: ID;
|
||||||
|
issueId: ID;
|
||||||
|
authorId: ID;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Issue {
|
||||||
|
id: ID;
|
||||||
|
/** Identificador visible, ej. "PLAT-42". */
|
||||||
|
identifier: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: IssueStatus;
|
||||||
|
priority: IssuePriority;
|
||||||
|
type: IssueType;
|
||||||
|
assigneeId: ID | null;
|
||||||
|
projectId: ID | null;
|
||||||
|
cycleId: ID | null;
|
||||||
|
labelIds: ID[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtros soportados por `listIssues`. */
|
||||||
|
export interface IssueFilters {
|
||||||
|
status?: IssueStatus[];
|
||||||
|
priority?: IssuePriority[];
|
||||||
|
assigneeId?: ID[];
|
||||||
|
projectId?: ID[];
|
||||||
|
cycleId?: ID[];
|
||||||
|
labelIds?: ID[];
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload para crear/actualizar un issue. */
|
||||||
|
export interface IssueInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status?: IssueStatus;
|
||||||
|
priority?: IssuePriority;
|
||||||
|
type?: IssueType;
|
||||||
|
assigneeId?: ID | null;
|
||||||
|
projectId?: ID | null;
|
||||||
|
cycleId?: ID | null;
|
||||||
|
labelIds?: ID[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user