ae92923c28
Animation primitives (6 new components): - AnimatedNumber: count-up with easeOutExpo, de-DE locale formatting - ShimmerSkeleton: diagonal gradient sweep replacing animate-pulse - FadeIn: framer-motion viewport-triggered fade + slide - StaggerList/StaggerItem: staggered children entrance - Sparkline: pure SVG inline trend chart with draw-in animation - ProgressRing: animated circular progress with CSS transitions Sidebar & page transitions: - Sliding nav indicator (framer-motion layoutId animation) - Icon frame hover glow (brand-color shadow) - Smooth section collapse/expand (AnimatePresence height animation) - PageTransition wrapper (fade-up on route change) - AnimatedModal component (scale + fade with custom bezier) - Notification badge bounce on count increase Dashboard animations: - StatCards: AnimatedNumber count-up + staggered FadeIn + budget color tinting - WidgetContainer: fade-slide-up on mount - Chargeability: animated percentages + inline utilization bars - ProjectTable/MyProjects: animated numbers + staggered row entrance Shimmer skeletons & table animations: - Replaced animate-pulse across 20+ loading states with shimmer gradient - Staggered row entrance (fadeSlideIn) on Resources, Projects, Allocations tables - hover-lift utility class for subtle card/row elevation on hover - Content-shaped skeletons (avatars, text bars, badges) Light mode surface depth: - Mesh gradient page background (subtle accent-tinted corners) - Enhanced card shadows (two-layer depth) - Sidebar glassmorphism upgrade (bg-white/60, backdrop-blur-2xl, saturate-150) - Toolbar sticky backdrop blur - Enhanced focus ring with brand-color glow Co-Authored-By: claude-flow <ruv@ruv.net>
145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import Link from "next/link";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import type { WidgetProps } from "../widget-registry.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { FadeIn } from "~/components/ui/FadeIn.js";
|
|
|
|
const STATUS_DOT: Record<string, string> = {
|
|
ACTIVE: "bg-green-500",
|
|
DRAFT: "bg-gray-400",
|
|
ON_HOLD: "bg-yellow-500",
|
|
COMPLETED: "bg-blue-500",
|
|
CANCELLED: "bg-red-400",
|
|
};
|
|
|
|
export function MyProjectsWidget({ config }: WidgetProps) {
|
|
const showFavorites = (config as { showFavorites?: boolean }).showFavorites !== false;
|
|
const showResponsible = (config as { showResponsible?: boolean }).showResponsible !== false;
|
|
|
|
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, {
|
|
staleTime: 30_000,
|
|
enabled: showFavorites,
|
|
});
|
|
|
|
const { data: projectsData } = trpc.project.listWithCosts.useQuery(
|
|
{ limit: 200 },
|
|
{ staleTime: 30_000 },
|
|
);
|
|
|
|
const { data: session } = trpc.user.me.useQuery(undefined, { staleTime: 60_000 });
|
|
|
|
const favorites = favoriteIds ?? [];
|
|
const allProjects = (projectsData?.projects ?? []) as Array<{
|
|
id: string;
|
|
shortCode: string;
|
|
name: string;
|
|
status: string;
|
|
responsiblePerson?: string | null;
|
|
budgetCents?: number;
|
|
startDate?: Date | string;
|
|
endDate?: Date | string;
|
|
}>;
|
|
|
|
const userName = session?.name ?? "";
|
|
|
|
const { favoriteProjects, responsibleProjects } = useMemo(() => {
|
|
const favSet = new Set(favorites);
|
|
const favProjects = showFavorites
|
|
? allProjects.filter((p) => favSet.has(p.id))
|
|
: [];
|
|
const respProjects = showResponsible && userName
|
|
? allProjects.filter((p) => p.responsiblePerson?.toLowerCase().includes(userName.toLowerCase()) && !favSet.has(p.id))
|
|
: [];
|
|
return { favoriteProjects: favProjects, responsibleProjects: respProjects };
|
|
}, [allProjects, favorites, showFavorites, showResponsible, userName]);
|
|
|
|
const toggleMutation = trpc.user.toggleFavoriteProject.useMutation({
|
|
onSuccess: () => {
|
|
void trpc.useUtils().user.getFavoriteProjectIds.invalidate();
|
|
},
|
|
});
|
|
|
|
const isEmpty = favoriteProjects.length === 0 && responsibleProjects.length === 0;
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{isEmpty && (
|
|
<div className="flex-1 flex items-center justify-center text-sm text-gray-400 dark:text-gray-500">
|
|
<div className="text-center">
|
|
<div className="text-2xl mb-1">⭐</div>
|
|
<p>No favorite projects yet.</p>
|
|
<p className="text-xs mt-1">Star projects from the project list to see them here.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isEmpty && (
|
|
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
|
{favoriteProjects.length > 0 && (
|
|
<div>
|
|
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-900/10 flex items-center">
|
|
Favorites
|
|
<InfoTooltip content="Projects you have starred. Click the star icon on any project to add or remove it from your favorites." />
|
|
</div>
|
|
{favoriteProjects.map((p, i) => (
|
|
<FadeIn key={p.id} delay={i * 0.03} direction="up">
|
|
<ProjectRow project={p} isFavorite onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
|
</FadeIn>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{responsibleProjects.length > 0 && (
|
|
<div>
|
|
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400 bg-brand-50/50 dark:bg-brand-900/10 flex items-center">
|
|
Responsible
|
|
<InfoTooltip content="Projects where you are listed as the responsible person. These are automatically shown based on your user name." />
|
|
</div>
|
|
{responsibleProjects.map((p, i) => (
|
|
<FadeIn key={p.id} delay={i * 0.03} direction="up">
|
|
<ProjectRow project={p} isFavorite={false} onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
|
</FadeIn>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectRow({
|
|
project,
|
|
isFavorite,
|
|
onToggleFavorite,
|
|
}: {
|
|
project: { id: string; shortCode: string; name: string; status: string };
|
|
isFavorite: boolean;
|
|
onToggleFavorite: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors group">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.preventDefault(); onToggleFavorite(); }}
|
|
className={`flex-shrink-0 text-sm transition-colors ${isFavorite ? "text-amber-500" : "text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100"}`}
|
|
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
|
>
|
|
{isFavorite ? "★" : "☆"}
|
|
</button>
|
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[project.status] ?? "bg-gray-400"}`} />
|
|
<Link
|
|
href={`/projects/${project.id}`}
|
|
className="min-w-0 flex-1 truncate text-sm text-gray-900 dark:text-gray-100 hover:text-brand-600 dark:hover:text-brand-400"
|
|
>
|
|
<span className="font-mono text-xs text-gray-400 dark:text-gray-500 mr-1.5">{project.shortCode}</span>
|
|
{project.name}
|
|
</Link>
|
|
<span className="flex-shrink-0 text-[10px] text-gray-400 dark:text-gray-500 uppercase" title={`Project status: ${project.status}`}>{project.status}</span>
|
|
</div>
|
|
);
|
|
}
|