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>
167 lines
6.8 KiB
TypeScript
167 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { BroadcastModal } from "./BroadcastModal.js";
|
|
import { CreateTaskModal } from "./CreateTaskModal.js";
|
|
|
|
function formatDate(date: string | Date): string {
|
|
const d = typeof date === "string" ? new Date(date) : date;
|
|
return d.toLocaleDateString("en-GB", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
const TARGET_LABELS: Record<string, string> = {
|
|
all: "All Users",
|
|
role: "By Role",
|
|
project: "By Project",
|
|
orgUnit: "By Org Unit",
|
|
user: "Specific User",
|
|
};
|
|
|
|
export function BroadcastManagementClient() {
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [showTaskModal, setShowTaskModal] = useState(false);
|
|
|
|
const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery(
|
|
{ limit: 50 },
|
|
{ staleTime: 30_000 },
|
|
);
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
function handleSuccess() {
|
|
void utils.notification.listBroadcasts.invalidate();
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Broadcast Management</h1>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTaskModal(true)}
|
|
className="inline-flex items-center gap-1.5 rounded-lg border border-brand-600 px-4 py-2 text-sm font-medium text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>
|
|
Create Task
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(true)}
|
|
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Send Broadcast
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<div className="space-y-3">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="rounded-xl border border-gray-200 dark:border-gray-700 p-4 animate-row-enter" style={{ animationDelay: `${i * 50}ms` }}>
|
|
<div className="h-4 w-1/2 shimmer-skeleton rounded" />
|
|
<div className="mt-2 h-3 w-1/3 shimmer-skeleton rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
{!isLoading && (
|
|
<>
|
|
{broadcasts.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
|
No broadcasts sent yet.
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
|
<tr>
|
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Title
|
|
</th>
|
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Target
|
|
</th>
|
|
<th scope="col" className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Recipients
|
|
</th>
|
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Sender
|
|
</th>
|
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Date
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
|
|
{broadcasts.map((b) => (
|
|
<tr key={b.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
|
{b.title}
|
|
</p>
|
|
{b.body && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
|
{b.body}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
|
{TARGET_LABELS[b.targetType] ?? b.targetType}
|
|
{b.targetValue && (
|
|
<span className="ml-1 text-gray-400 dark:text-gray-500">({b.targetValue})</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-right text-gray-600 dark:text-gray-300">
|
|
{b.recipientCount ?? 0}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
|
{b.sender?.name ?? b.sender?.email ?? "-"}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
|
{b.sentAt ? formatDate(b.sentAt) : "Scheduled"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Broadcast Modal */}
|
|
{showModal && (
|
|
<BroadcastModal
|
|
onClose={() => setShowModal(false)}
|
|
onSuccess={handleSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Create Task Modal */}
|
|
{showTaskModal && (
|
|
<CreateTaskModal
|
|
onClose={() => setShowTaskModal(false)}
|
|
onSuccess={handleSuccess}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|