137 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|