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