Files
proyectos/src/app/projects/page.tsx
T

137 lines
4.6 KiB
TypeScript

"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, useWorkflowStates } 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();
const { doneKeys } = useWorkflowStates();
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 (doneKeys.has(issue.status)) 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>
);
}