feat: modulo de proyectos frontend (Next 16 + Bootstrap 5)

This commit is contained in:
Nurfog committed 2026-10-13 17:32:55 -03:00
commit 2dc41ed4fa
43 files changed
+5894

No files matched your search

+19
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+132
View File
@@ -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.
+32
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
};
export default nextConfig;
+1382
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
}
}
+87
View File
@@ -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));
}
+56
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+844
View File
@@ -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%;
}
}
+69
View File
@@ -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>
);
}
+336
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+8
View File
@@ -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");
}
+132
View File
@@ -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>
);
}
+135
View File
@@ -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>
);
}
+159
View File
@@ -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>
);
}
+145
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+211
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+61
View File
@@ -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}
/>
);
}
+15
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+40
View File
@@ -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 };
}
+41
View File
@@ -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>;
}
+146
View File
@@ -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 }),
}),
};
+23
View File
@@ -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";
+181
View File
@@ -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;
},
};
+81
View File
@@ -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);
}
+70
View File
@@ -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);
}
+53
View File
@@ -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
View File
@@ -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",
},
];
+87
View File
@@ -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],
);
}
+108
View File
@@ -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[];
}
+41
View File
@@ -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"
]
}