178 lines
6.3 KiB
TypeScript
178 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { FolderKanbanIcon, PlusIcon, SearchIcon } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemFooter,
|
|
ItemHeader,
|
|
ItemMedia,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import { CreateProjectDialog } from "@/components/frontend/projects/CreateProjectDialog";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface ProjectListItem {
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly key: string;
|
|
readonly status: string;
|
|
readonly lead: { id: number; displayName: string } | null;
|
|
readonly description?: string | null;
|
|
readonly createdAt: string;
|
|
readonly updatedAt: string;
|
|
}
|
|
|
|
export interface ProjectsViewProps {
|
|
readonly projects: readonly ProjectListItem[];
|
|
readonly currentUserId: number;
|
|
readonly isAdmin: boolean;
|
|
}
|
|
|
|
const PROJECT_STATUS_STYLES: Readonly<Record<string, string>> = {
|
|
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
|
planning: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
|
on_hold: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
|
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
|
archived: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
|
};
|
|
|
|
function labelFor(value: string): string {
|
|
return value
|
|
.replaceAll("_", " ")
|
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|
|
|
|
function dateFor(value: string): string {
|
|
return new Date(value).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
export function ProjectsView({ projects, currentUserId: _currentUserId, isAdmin }: ProjectsViewProps) {
|
|
const [search, setSearch] = useState("");
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const query = search.trim().toLowerCase();
|
|
const filteredProjects = useMemo(
|
|
() =>
|
|
projects.filter((project) =>
|
|
query
|
|
? `${project.name} ${project.key} ${project.description ?? ""}`.toLowerCase().includes(query)
|
|
: true,
|
|
),
|
|
[projects, query],
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Projects
|
|
</h2>
|
|
<span className="text-xs text-muted-foreground">{projects.length} total</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Plan work, track progress, and ship with your team.
|
|
</p>
|
|
</div>
|
|
{isAdmin && (
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Create project
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative w-full lg:max-w-xs">
|
|
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
aria-label="Search projects"
|
|
className="pl-8"
|
|
placeholder="Search projects..."
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{filteredProjects.length === 0 ? (
|
|
<Item variant="outline">
|
|
<ItemMedia variant="icon">
|
|
<FolderKanbanIcon className="text-muted-foreground" />
|
|
</ItemMedia>
|
|
<ItemContent className="items-center gap-1">
|
|
<ItemTitle>
|
|
{projects.length === 0 ? "No projects yet" : "No matching projects"}
|
|
</ItemTitle>
|
|
<ItemDescription>
|
|
{projects.length === 0
|
|
? "Create a project to start organizing your work."
|
|
: "Try a different search phrase."}
|
|
</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
|
{filteredProjects.map((project) => (
|
|
<Link
|
|
key={project.id}
|
|
href={`/projects/${project.key}/board`}
|
|
className="group block h-full"
|
|
>
|
|
<Item
|
|
variant="outline"
|
|
className="h-full min-h-44 items-start transition-colors group-hover:bg-accent/50"
|
|
>
|
|
<ItemHeader className="items-center">
|
|
<Badge variant="outline" className="font-mono text-[10px] tracking-wide">
|
|
{project.key}
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"text-[10px] uppercase tracking-wide",
|
|
PROJECT_STATUS_STYLES[project.status] ??
|
|
"bg-muted/50 text-muted-foreground border-border",
|
|
)}
|
|
>
|
|
{labelFor(project.status)}
|
|
</Badge>
|
|
</ItemHeader>
|
|
<ItemContent className="w-full min-w-0 flex-1 gap-2">
|
|
<ItemTitle className="line-clamp-2 text-base font-semibold">
|
|
{project.name}
|
|
</ItemTitle>
|
|
{project.description && typeof project.description === "string" && (
|
|
<ItemDescription>{project.description}</ItemDescription>
|
|
)}
|
|
{!project.description && typeof project.description !== "string" && (
|
|
<ItemDescription>No description provided.</ItemDescription>
|
|
)}
|
|
</ItemContent>
|
|
<ItemFooter className="w-full flex-wrap items-end gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
<span className="min-w-0 flex-1 truncate">
|
|
Lead: {project.lead?.displayName ?? "Unassigned"}
|
|
</span>
|
|
<span className="shrink-0 font-mono text-[10px]">
|
|
Created {dateFor(project.createdAt)}
|
|
</span>
|
|
</ItemFooter>
|
|
</Item>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
|
|
</div>
|
|
);
|
|
}
|