Commit 19c951e4 authored by Administrator's avatar Administrator

Update 38 files via Son of Anton

parent ec80f55d
NEXT_PUBLIC_API_URL=http://localhost:3001/api
NEXT_PUBLIC_WS_URL=http://localhost:3001
NEXT_PUBLIC_APP_NAME=The Grind
\ No newline at end of file
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '9000',
pathname: '/**',
},
],
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'}/:path*`,
},
];
},
};
module.exports = nextConfig;
\ No newline at end of file
{
"name": "the-grind-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5",
"socket.io-client": "^4.8.0",
"react-hook-form": "^7.53.0",
"zod": "^3.23.8",
"@hookform/resolvers": "^3.9.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.11.0",
"sonner": "^1.5.0",
"lucide-react": "^0.447.0",
"next-themes": "^0.3.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.3",
"class-variance-authority": "^0.7.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-alert-dialog": "^1.1.2"
},
"devDependencies": {
"typescript": "^5.6.3",
"@types/node": "^20.16.11",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.15"
}
}
\ No newline at end of file
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
\ No newline at end of file
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<div className="w-full max-w-md">{children}</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { Eye, EyeOff, Loader2, LogIn } from 'lucide-react';
export default function LoginPage() {
const [loginField, setLoginField] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login, isLoading, error, clearError } = useAuthStore();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
await login(loginField, password);
router.push('/');
} catch {
// Error is handled in the store
}
};
return (
<div className="space-y-6">
{/* Logo / Branding */}
<div className="text-center space-y-2">
<div className="text-4xl font-black tracking-tighter">
THE GRIND
</div>
<p className="text-sm text-muted-foreground">
AL-Arcade HR Platform
</p>
</div>
{/* Login Card */}
<div className="bg-card rounded-xl border shadow-lg p-6 space-y-6">
<div className="space-y-1">
<h1 className="text-xl font-semibold">Sign In</h1>
<p className="text-sm text-muted-foreground">
Enter your username or email to continue
</p>
</div>
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive text-sm rounded-lg p-3">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="login" className="text-sm font-medium">
Username or Email
</label>
<input
id="login"
type="text"
value={loginField}
onChange={(e) => setLoginField(e.target.value)}
placeholder="Enter your username or email"
required
autoFocus
autoComplete="username"
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoComplete="current-password"
className="w-full px-3 py-2 pr-10 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading || !loginField || !password}
className="w-full bg-primary text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transition-colors"
>
{isLoading ? (
<>
<Loader2 size={16} className="animate-spin" />
Signing in...
</>
) : (
<>
<LogIn size={16} />
Sign In
</>
)}
</button>
</form>
</div>
<p className="text-center text-xs text-muted-foreground">
No account? Contact your administrator for an invitation.
</p>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { BlockingOverlay } from '@/components/notifications/blocking-overlay';
import { useHud } from '@/hooks/use-hud';
import { useNotifications } from '@/hooks/use-notifications';
import { useThemeStore } from '@/stores/theme.store';
import { cn } from '@/lib/utils';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isAuthenticated, loadUser } = useAuthStore();
const { sidebarCollapsed } = useThemeStore();
const router = useRouter();
// Initialize auth
useEffect(() => {
loadUser();
}, [loadUser]);
// Redirect if not authenticated
useEffect(() => {
if (!isAuthenticated && typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (!token) {
router.push('/login');
}
}
}, [isAuthenticated, router]);
// Initialize HUD and notifications
useHud();
useNotifications();
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="min-h-screen bg-background">
{/* Blocking notification overlay */}
<BlockingOverlay />
{/* Sidebar */}
<Sidebar />
{/* Main content area */}
<div
className={cn(
'transition-all duration-300 ease-in-out',
sidebarCollapsed ? 'ml-16' : 'ml-64',
)}
>
{/* Top bar */}
<Topbar />
{/* Page content */}
<main className="p-6">{children}</main>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { apiGet } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { StatusBadge } from '@/components/shared/status-badge';
import { formatDate, formatShortDate, relativeTime } from '@/lib/date';
import { formatEgp } from '@/lib/utils';
import {
ListTodo,
Clock,
AlertTriangle,
CheckCircle2,
Flame,
TrendingUp,
Users,
FileText,
Calendar,
} from 'lucide-react';
import Link from 'next/link';
export default function DashboardPage() {
const user = useAuthStore((s) => s.user);
const [dashboard, setDashboard] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!user) return;
apiGet('/analytics/dashboard')
.then((res) => setDashboard(res.data))
.catch((err) => console.error('Failed to load dashboard:', err))
.finally(() => setIsLoading(false));
}, [user]);
if (isLoading) return <PageLoadingSkeleton />;
if (!dashboard) {
return (
<div>
<PageHeader title={`Welcome, ${user?.firstName}`} />
<p className="text-muted-foreground">Failed to load dashboard data.</p>
</div>
);
}
// Route to role-specific dashboard
if (user?.role === 'CONTRACTOR') {
return <ContractorDashboard data={dashboard} user={user} />;
}
if (user?.role === 'TEAM_LEAD') {
return <ProjectLeaderDashboard data={dashboard} user={user} />;
}
// Admin / Super Admin
return <AdminDashboard data={dashboard} user={user} />;
}
function ContractorDashboard({ data, user }: { data: any; user: any }) {
return (
<div className="space-y-6">
<PageHeader
title={`Welcome back, ${user.firstName}`}
description="Here's your overview for today"
/>
{/* Stats row */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={ListTodo}
label="Active Tasks"
value={data.tasks?.total || 0}
sublabel={`${data.tasks?.doing || 0} in progress`}
/>
<StatCard
icon={Clock}
label="Days Reported"
value={`${data.thisMonth?.daysReported || 0} / ${data.thisMonth?.expectedDays || 0}`}
sublabel={formatDate(new Date())}
/>
<StatCard
icon={Flame}
label="Current Streak"
value={`${data.streak?.current || 0} days`}
sublabel={`Best: ${data.streak?.best || 0}`}
highlight
/>
<StatCard
icon={AlertTriangle}
label="Unread"
value={data.unreadNotifications || 0}
sublabel="notifications"
/>
</div>
{/* Tasks */}
{data.tasks?.cards?.length > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<ListTodo size={16} />
My Tasks
</h3>
<div className="space-y-2">
{data.tasks.cards.slice(0, 10).map((card: any) => (
<Link
key={card.id}
href={`/boards/${card.boardKey}`}
className="flex items-center justify-between p-2 rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center gap-3">
<StatusBadge status={card.columnType} />
<div>
<p className="text-sm font-medium">{card.title}</p>
<p className="text-xs text-muted-foreground">
{card.cardNumber} · {card.boardName}
</p>
</div>
</div>
{card.dueDate && (
<span
className={`text-xs ${card.isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground'}`}
>
{formatShortDate(card.dueDate)}
</span>
)}
</Link>
))}
</div>
</div>
)}
{/* Upcoming deadlines */}
{data.upcomingDeadlines?.length > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Clock size={16} />
Upcoming Deadlines
</h3>
<div className="space-y-2">
{data.upcomingDeadlines.map((card: any) => (
<div key={card.id} className="flex items-center justify-between p-2 text-sm">
<span>{card.cardNumber}: {card.title}</span>
<span className="text-muted-foreground">{formatShortDate(card.dueDate)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
function ProjectLeaderDashboard({ data, user }: { data: any; user: any }) {
return (
<div className="space-y-6">
<PageHeader
title={`Welcome, ${user.firstName}`}
description="Team overview and pending actions"
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard icon={Users} label="Team Size" value={data.teamSize || 0} sublabel="active members" />
<StatCard icon={FileText} label="Pending Reviews" value={data.pendingReviews || 0} sublabel="reports to review" />
<StatCard icon={AlertTriangle} label="At-Risk Tasks" value={data.atRiskTasks?.length || 0} sublabel="overdue or due soon" />
<StatCard icon={CheckCircle2} label="Pending Evals" value={data.pendingEvaluations || 0} sublabel="to submit" />
</div>
{/* Team report status */}
{data.teamReportStatus?.length > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Team Report Status — Today</h3>
<div className="space-y-2">
{data.teamReportStatus.map((member: any) => (
<div key={member.id} className="flex items-center justify-between p-2 text-sm">
<span>{member.firstName} {member.lastName}</span>
{member.reported ? (
<span className="text-emerald-500 flex items-center gap-1">
<CheckCircle2 size={14} /> Reported
</span>
) : (
<span className="text-yellow-500">⏳ Pending</span>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
function AdminDashboard({ data, user }: { data: any; user: any }) {
return (
<div className="space-y-6">
<PageHeader
title={`Welcome, ${user.firstName}`}
description="Organization overview"
/>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={Users}
label="Active Contractors"
value={data.contractors?.total || 0}
sublabel={`${data.contractors?.fullTimers || 0} FT · ${data.contractors?.interns || 0} INT`}
/>
<StatCard
icon={TrendingUp}
label="Bounties This Month"
value={formatEgp(data.bountiesThisMonth?.totalPiasters || 0)}
sublabel={`${data.bountiesThisMonth?.count || 0} paid out`}
/>
<StatCard
icon={AlertTriangle}
label="Deductions This Month"
value={formatEgp(data.deductionsThisMonth?.totalPiasters || 0)}
sublabel={`${data.deductionsThisMonth?.count || 0} applied`}
/>
<StatCard
icon={Clock}
label="Pending Actions"
value={data.pendingActions?.total || 0}
sublabel="items need attention"
/>
</div>
{/* Pending actions breakdown */}
{data.pendingActions?.total > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Pending Actions</h3>
<div className="grid gap-2 sm:grid-cols-3">
{data.pendingActions.deductionReviews > 0 && (
<Link href="/admin/deductions" className="p-3 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
<p className="text-2xl font-bold">{data.pendingActions.deductionReviews}</p>
<p className="text-xs text-muted-foreground">Deduction Reviews</p>
</Link>
)}
{data.pendingActions.adjustments > 0 && (
<Link href="/admin/adjustments" className="p-3 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
<p className="text-2xl font-bold">{data.pendingActions.adjustments}</p>
<p className="text-xs text-muted-foreground">Pending Adjustments</p>
</Link>
)}
{data.pendingActions.scheduleChanges > 0 && (
<Link href="/admin/contractors" className="p-3 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
<p className="text-2xl font-bold">{data.pendingActions.scheduleChanges}</p>
<p className="text-xs text-muted-foreground">Schedule Requests</p>
</Link>
)}
</div>
</div>
)}
{/* Active PIPs */}
{data.activePips?.length > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<AlertTriangle size={16} className="text-orange-500" />
Active PIPs
</h3>
<div className="space-y-2">
{data.activePips.map((pip: any) => (
<Link
key={pip.id}
href="/admin/pips"
className="flex items-center justify-between p-2 rounded-lg hover:bg-accent transition-colors text-sm"
>
<span>{pip.user?.firstName} {pip.user?.lastName}</span>
<span className="text-muted-foreground">{formatDate(pip.endDate)}</span>
</Link>
))}
</div>
</div>
)}
{/* Payroll status */}
{data.payroll && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Payroll Status</h3>
<div className="flex items-center gap-3">
<StatusBadge status={data.payroll.status} />
{data.payroll.totalNetPiasters > 0 && (
<span className="text-sm text-muted-foreground">
Net: {formatEgp(data.payroll.totalNetPiasters)} · {data.payroll.contractorCount} contractors
</span>
)}
</div>
</div>
)}
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
sublabel,
highlight,
}: {
icon: React.ElementType;
label: string;
value: string | number;
sublabel?: string;
highlight?: boolean;
}) {
return (
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Icon size={16} />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<p className={`text-2xl font-bold ${highlight ? 'text-orange-500' : ''}`}>{value}</p>
{sublabel && <p className="text-xs text-muted-foreground mt-0.5">{sublabel}</p>}
</div>
);
}
\ No newline at end of file
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-border: 220 13% 91%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/30;
}
\ No newline at end of file
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { ThemeProvider } from 'next-themes';
import { Toaster } from 'sonner';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'The Grind — AL-Arcade HR Platform',
description: 'Gamified HR, Project Management & Internal Operations Platform',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster
position="top-right"
richColors
closeButton
duration={5000}
/>
</ThemeProvider>
</body>
</html>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { useHudStore } from '@/stores/hud.store';
import { formatEgp, getHealthStatus, cn } from '@/lib/utils';
import { formatMonthYear } from '@/lib/date';
import { Flame, TrendingDown, TrendingUp, ChevronDown, ChevronUp, Shield, AlertTriangle, Skull } from 'lucide-react';
export function HudBar() {
const [isExpanded, setIsExpanded] = useState(false);
const {
actualSalaryPiasters,
liveSalaryPiasters,
totalDeductionsPiasters,
totalBountiesPiasters,
deductionCount,
bountyCount,
currentStreak,
bestStreak,
month,
year,
lineItems,
isLoaded,
pulseAnimation,
} = useHudStore();
if (!isLoaded) {
return (
<div className="h-8 w-64 bg-muted rounded-md animate-pulse" />
);
}
const retentionPercent =
actualSalaryPiasters > 0
? Math.round((liveSalaryPiasters / actualSalaryPiasters) * 100)
: 100;
const barWidth = Math.min(100, Math.max(0, retentionPercent));
const health = getHealthStatus(deductionCount, retentionPercent);
const barColor =
retentionPercent > 100
? 'bg-amber-400'
: retentionPercent >= 80
? 'bg-emerald-500'
: retentionPercent >= 60
? 'bg-yellow-500'
: 'bg-red-500';
const HealthIcon =
health === 'HEALTHY' ? Shield : health === 'WARNING' ? AlertTriangle : Skull;
const healthColor =
health === 'HEALTHY'
? 'text-emerald-500'
: health === 'WARNING'
? 'text-yellow-500'
: 'text-red-500 animate-pulse';
return (
<div className="flex-1 max-w-xl">
{/* Compact bar */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'w-full text-left rounded-lg border px-3 py-1.5 transition-all hover:border-primary/30',
pulseAnimation === 'red' && 'animate-hud-pulse-red',
pulseAnimation === 'gold' && 'animate-hud-pulse-gold',
)}
>
<div className="flex items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium text-muted-foreground">
💰 {formatMonthYear(month, year)}
</span>
<HealthIcon size={14} className={healthColor} />
</div>
<div className="flex items-center gap-3">
{deductionCount > 0 && (
<span className="text-red-500 flex items-center gap-0.5">
<TrendingDown size={12} />
{deductionCount} (-{formatEgp(totalDeductionsPiasters)})
</span>
)}
{bountyCount > 0 && (
<span className="text-emerald-500 flex items-center gap-0.5">
<TrendingUp size={12} />
{bountyCount} (+{formatEgp(totalBountiesPiasters)})
</span>
)}
<span className="font-bold">
{formatEgp(liveSalaryPiasters)}
</span>
<span className="text-muted-foreground">/ {formatEgp(actualSalaryPiasters)}</span>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</div>
</div>
{/* Progress bar */}
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-700 ease-out', barColor)}
style={{ width: `${barWidth}%` }}
/>
</div>
{/* Streak */}
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="flex items-center gap-0.5">
<Flame size={10} className="text-orange-500" />
{currentStreak}-day streak
</span>
<span>Best: {bestStreak}</span>
</div>
</button>
{/* Expanded breakdown */}
{isExpanded && (
<div className="mt-1 rounded-lg border bg-card p-3 shadow-lg animate-fade-in">
<div className="space-y-1.5 text-xs">
<div className="flex justify-between font-medium">
<span>Actual Salary</span>
<span className="text-emerald-600">+{formatEgp(actualSalaryPiasters)}</span>
</div>
{lineItems.map((item) => (
<div key={item.id} className="flex justify-between">
<span className="text-muted-foreground truncate mr-2">{item.label}</span>
<span className={item.amountPiasters >= 0 ? 'text-emerald-600' : 'text-red-500'}>
{item.amountPiasters >= 0 ? '+' : ''}{formatEgp(item.amountPiasters)}
</span>
</div>
))}
<div className="border-t pt-1.5 mt-1.5 flex justify-between font-bold">
<span>= Live Salary</span>
<span>{formatEgp(liveSalaryPiasters)}</span>
</div>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import {
LayoutDashboard,
Kanban,
ListTodo,
FileText,
Wallet,
MessageSquare,
Bell,
GraduationCap,
Calendar,
Users,
Settings,
Shield,
BarChart3,
Clock,
ClipboardList,
UserCog,
Send,
BookOpen,
Building,
ChevronLeft,
ChevronRight,
DollarSign,
Star,
AlertTriangle,
FileKey,
Webhook,
Search,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth.store';
import { useThemeStore } from '@/stores/theme.store';
import { cn } from '@/lib/utils';
import type { Role } from '@/lib/permissions';
interface NavItem {
label: string;
href: string;
icon: React.ElementType;
roles?: Role[];
badge?: number;
}
interface NavSection {
title: string;
items: NavItem[];
}
const navigation: NavSection[] = [
{
title: 'Main',
items: [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard },
{ label: 'Boards', href: '/boards', icon: Kanban },
{ label: 'My Tasks', href: '/my-tasks', icon: ListTodo },
{ label: 'Reports', href: '/reports', icon: FileText },
{ label: 'Salary', href: '/salary', icon: Wallet },
],
},
{
title: 'Communication',
items: [
{ label: 'Messages', href: '/messages', icon: MessageSquare },
{ label: 'Notifications', href: '/notifications', icon: Bell },
],
},
{
title: 'Growth',
items: [
{ label: 'Evaluations', href: '/evaluations', icon: Star },
{ label: 'Learning', href: '/learning', icon: GraduationCap },
],
},
{
title: 'Schedule',
items: [
{ label: 'My Schedule', href: '/schedule', icon: Clock },
{ label: 'Availability', href: '/availability', icon: Calendar },
{ label: 'Meetings', href: '/meetings', icon: Users },
],
},
{
title: 'Admin',
items: [
{ label: 'Contractors', href: '/admin/contractors', icon: UserCog, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Onboarding', href: '/admin/onboarding', icon: ClipboardList, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Invites', href: '/admin/invites', icon: Send, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Deductions', href: '/admin/deductions', icon: AlertTriangle, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Payroll', href: '/admin/payroll', icon: DollarSign, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Evaluations', href: '/admin/evaluations', icon: Star, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'PIPs', href: '/admin/pips', icon: AlertTriangle, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Bounties', href: '/admin/bounties', icon: Wallet, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Adjustments', href: '/admin/adjustments', icon: DollarSign, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Analytics', href: '/admin/analytics', icon: BarChart3, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Holidays', href: '/admin/holidays', icon: Calendar, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Policies', href: '/admin/policies', icon: BookOpen, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Notices', href: '/admin/notices', icon: Bell, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Contracts', href: '/admin/contracts', icon: FileText, roles: ['SUPER_ADMIN', 'ADMIN'] },
],
},
{
title: 'System',
items: [
{ label: 'Audit Trail', href: '/admin/audit-trail', icon: Shield, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Settings', href: '/admin/settings', icon: Settings, roles: ['SUPER_ADMIN'] },
{ label: 'API Keys', href: '/admin/api-keys', icon: FileKey, roles: ['SUPER_ADMIN'] },
{ label: 'Webhooks', href: '/admin/webhooks', icon: Webhook, roles: ['SUPER_ADMIN'] },
{ label: 'System Health', href: '/admin/system-health', icon: BarChart3, roles: ['SUPER_ADMIN'] },
{ label: 'Control Panel', href: '/admin/control-panel', icon: Building, roles: ['SUPER_ADMIN'] },
],
},
];
export function Sidebar() {
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const userRole = (user?.role || 'CONTRACTOR') as Role;
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen bg-sidebar border-r border-sidebar-border flex flex-col transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64',
)}
>
{/* Logo */}
<div className="h-14 flex items-center justify-between px-4 border-b border-sidebar-border">
{!sidebarCollapsed && (
<Link href="/" className="font-black text-lg tracking-tighter text-sidebar-foreground">
THE GRIND
</Link>
)}
<button
onClick={toggleSidebar}
className="p-1.5 rounded-md hover:bg-sidebar-accent text-sidebar-foreground/60 hover:text-sidebar-foreground transition-colors"
>
{sidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-6">
{navigation.map((section) => {
const visibleItems = section.items.filter(
(item) => !item.roles || item.roles.includes(userRole),
);
if (visibleItems.length === 0) return null;
return (
<div key={section.title}>
{!sidebarCollapsed && (
<p className="px-3 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/40">
{section.title}
</p>
)}
<div className="space-y-0.5">
{visibleItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground',
sidebarCollapsed && 'justify-center px-0',
)}
title={sidebarCollapsed ? item.label : undefined}
>
<item.icon size={18} className="shrink-0" />
{!sidebarCollapsed && <span>{item.label}</span>}
</Link>
);
})}
</div>
</div>
);
})}
</nav>
{/* User section */}
{!sidebarCollapsed && user && (
<div className="p-3 border-t border-sidebar-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
{(user.firstName?.[0] || '') + (user.lastName?.[0] || '')}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-sidebar-foreground">
{user.firstName} {user.lastName}
</p>
<p className="text-[10px] text-sidebar-foreground/50 uppercase">
{user.role.replace('_', ' ')}
</p>
</div>
</div>
</div>
)}
</aside>
);
}
\ No newline at end of file
'use client';
import { useRouter } from 'next/navigation';
import { Bell, MessageSquare, Search, LogOut, User, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useAuthStore } from '@/stores/auth.store';
import { useNotificationStore } from '@/stores/notification.store';
import { HudBar } from '@/components/hud/hud-bar';
import { cn } from '@/lib/utils';
export function Topbar() {
const router = useRouter();
const { user, logout } = useAuthStore();
const { unreadCount } = useNotificationStore();
const { theme, setTheme } = useTheme();
const isContractor = user?.role === 'CONTRACTOR';
return (
<header className="sticky top-0 z-30 h-14 bg-background/80 backdrop-blur-sm border-b flex items-center justify-between px-6">
{/* Left: HUD (for contractors) or search */}
<div className="flex items-center gap-4 flex-1">
{isContractor ? (
<HudBar />
) : (
<button
onClick={() => router.push('/admin/analytics')}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Search size={16} />
<span className="hidden sm:inline">Search... (Ctrl+K)</span>
</button>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1">
{/* Theme toggle */}
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
{/* Messages */}
<button
onClick={() => router.push('/messages')}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors relative"
title="Messages"
>
<MessageSquare size={18} />
</button>
{/* Notifications */}
<button
onClick={() => router.push('/notifications')}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors relative"
title="Notifications"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-destructive text-destructive-foreground rounded-full text-[10px] font-bold flex items-center justify-center px-1">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* User menu */}
<div className="flex items-center gap-2 ml-2 pl-2 border-l">
<button
onClick={() => router.push('/profile')}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Profile"
>
<User size={18} />
</button>
<button
onClick={logout}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-destructive transition-colors"
title="Logout"
>
<LogOut size={18} />
</button>
</div>
</div>
</header>
);
}
\ No newline at end of file
'use client';
import { useNotificationStore } from '@/stores/notification.store';
import { apiPut } from '@/lib/api';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { useState } from 'react';
export function BlockingOverlay() {
const { blockingQueue, acknowledgeBlocking } = useNotificationStore();
const [isAcknowledging, setIsAcknowledging] = useState(false);
const currentBlocking = blockingQueue[0];
if (!currentBlocking) return null;
const handleAcknowledge = async () => {
setIsAcknowledging(true);
try {
await apiPut(`/notifications/${currentBlocking.id}/acknowledge`);
acknowledgeBlocking(currentBlocking.id);
} catch (err) {
console.error('Failed to acknowledge:', err);
} finally {
setIsAcknowledging(false);
}
};
return (
<div className="fixed inset-0 z-[100] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-card rounded-xl border shadow-2xl max-w-lg w-full p-6 space-y-6 animate-fade-in">
{/* Icon */}
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle size={32} className="text-destructive" />
</div>
</div>
{/* Content */}
<div className="text-center space-y-2">
<h2 className="text-xl font-bold">{currentBlocking.title}</h2>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{currentBlocking.message}
</p>
</div>
{/* Disclaimer */}
<p className="text-[11px] text-muted-foreground text-center italic">
Acknowledgment does not mean agreement. You are confirming you have seen this notification.
</p>
{/* Queue indicator */}
{blockingQueue.length > 1 && (
<p className="text-xs text-center text-muted-foreground">
{blockingQueue.length - 1} more notification{blockingQueue.length - 1 > 1 ? 's' : ''} after this
</p>
)}
{/* Acknowledge button */}
<button
onClick={handleAcknowledge}
disabled={isAcknowledging}
className="w-full bg-primary text-primary-foreground rounded-lg px-4 py-3 font-medium hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{isAcknowledging ? (
<>
<Loader2 size={16} className="animate-spin" />
Acknowledging...
</>
) : (
'I Acknowledge'
)}
</button>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { Loader2, AlertTriangle } from 'lucide-react';
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => Promise<void> | void;
title: string;
description: string;
confirmLabel?: string;
destructive?: boolean;
requireConfirmText?: string;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
destructive = false,
requireConfirmText,
}: ConfirmDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
if (!open) return null;
const canConfirm = !requireConfirmText || confirmInput === requireConfirmText;
const handleConfirm = async () => {
setIsLoading(true);
try {
await onConfirm();
onClose();
} catch (err) {
console.error('Confirm action failed:', err);
} finally {
setIsLoading(false);
setConfirmInput('');
}
};
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-card rounded-xl border shadow-lg max-w-md w-full p-6 space-y-4 animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3">
{destructive && (
<div className="w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
<AlertTriangle size={20} className="text-destructive" />
</div>
)}
<div>
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
</div>
{requireConfirmText && (
<div className="space-y-2">
<p className="text-sm">
Type <strong>{requireConfirmText}</strong> to confirm:
</p>
<input
type="text"
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
/>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={isLoading || !canConfirm}
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50 ${
destructive
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
}`}
>
{isLoading && <Loader2 size={14} className="animate-spin" />}
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
\ No newline at end of file
import { Inbox } from 'lucide-react';
interface EmptyStateProps {
icon?: React.ElementType;
title: string;
description?: string;
action?: React.ReactNode;
}
export function EmptyState({
icon: Icon = Inbox,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Icon size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground mt-1 max-w-md">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
\ No newline at end of file
import { cn } from '@/lib/utils';
interface LoadingSkeletonProps {
className?: string;
}
export function LoadingSkeleton({ className }: LoadingSkeletonProps) {
return (
<div className={cn('animate-pulse rounded-md bg-muted', className)} />
);
}
export function PageLoadingSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-2">
<LoadingSkeleton className="h-8 w-64" />
<LoadingSkeleton className="h-4 w-96" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<LoadingSkeleton key={i} className="h-32" />
))}
</div>
<LoadingSkeleton className="h-64" />
</div>
);
}
export function TableLoadingSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-3">
<LoadingSkeleton className="h-10 w-full" />
{[...Array(rows)].map((_, i) => (
<LoadingSkeleton key={i} className="h-14 w-full" />
))}
</div>
);
}
\ No newline at end of file
interface PageHeaderProps {
title: string;
description?: string;
actions?: React.ReactNode;
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
\ No newline at end of file
'use client';
import { useRole } from '@/hooks/use-role';
import type { Role } from '@/lib/permissions';
interface PermissionGateProps {
roles: Role[];
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function PermissionGate({ roles, children, fallback = null }: PermissionGateProps) {
const { hasRole } = useRole();
if (!hasRole(...roles)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
\ No newline at end of file
import { cn } from '@/lib/utils';
interface StatusBadgeProps {
status: string;
className?: string;
}
const statusStyles: Record<string, string> = {
ACTIVE: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
ONBOARDING: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
ON_PIP: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20',
SUSPENDED: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20',
OFFBOARDED: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
TERMINATED: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
PENDING: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20',
APPROVED: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
REJECTED: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
CANCELLED: 'bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-500/20',
UPHELD: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
REDUCED: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20',
DISMISSED: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
AUTO_APPLIED: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
SUBMITTED: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
LATE: 'bg-orange-500/10 text-orange-700 dark:text-orange-400 border-orange-500/20',
DRAFT: 'bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-500/20',
COMPILED: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
ACKNOWLEDGED: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
PASSED: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
FAILED: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
PAID: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
PROCESSING: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
CALCULATED: 'bg-purple-500/10 text-purple-700 dark:text-purple-400 border-purple-500/20',
SCHEDULED: 'bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-500/20',
COMPLETED: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
OVERDUE: 'bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20',
EXTENDED: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-500/20',
};
const defaultStyle = 'bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-500/20';
export function StatusBadge({ status, className }: StatusBadgeProps) {
const style = statusStyles[status] || defaultStyle;
const label = status.replace(/_/g, ' ');
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium border',
style,
className,
)}
>
{label}
</span>
);
}
\ No newline at end of file
import { cn, getInitials } from '@/lib/utils';
interface UserAvatarProps {
firstName: string;
lastName: string;
avatar?: string | null;
size?: 'xs' | 'sm' | 'md' | 'lg';
className?: string;
}
const sizeClasses = {
xs: 'w-6 h-6 text-[10px]',
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-14 h-14 text-base',
};
export function UserAvatar({ firstName, lastName, avatar, size = 'sm', className }: UserAvatarProps) {
const initials = getInitials(firstName, lastName);
if (avatar) {
return (
<img
src={avatar}
alt={`${firstName} ${lastName}`}
className={cn('rounded-full object-cover', sizeClasses[size], className)}
/>
);
}
return (
<div
className={cn(
'rounded-full bg-primary/10 text-primary font-bold flex items-center justify-center',
sizeClasses[size],
className,
)}
>
{initials}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
export function useAuth(requireAuth = true) {
const { user, isAuthenticated, isLoading, loadUser, logout } = useAuthStore();
const router = useRouter();
useEffect(() => {
loadUser();
}, [loadUser]);
useEffect(() => {
if (requireAuth && !isLoading && !isAuthenticated) {
router.push('/login');
}
}, [requireAuth, isAuthenticated, isLoading, router]);
return { user, isAuthenticated, isLoading, logout };
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
\ No newline at end of file
'use client';
import { useEffect } from 'react';
import { useHudStore } from '@/stores/hud.store';
import { useSocket } from './use-socket';
import { useAuthStore } from '@/stores/auth.store';
import { apiGet } from '@/lib/api';
import { toast } from 'sonner';
import { formatEgp } from '@/lib/utils';
export function useHud() {
const { on } = useSocket();
const user = useAuthStore((s) => s.user);
const { setHudData, triggerPulse } = useHudStore();
// Load initial HUD data
useEffect(() => {
if (!user || user.role !== 'CONTRACTOR') return;
apiGet(`/salary/hud/${user.id}`)
.then((res) => {
if (res.data) {
setHudData(res.data);
}
})
.catch((err) => {
console.error('Failed to load HUD data:', err);
});
}, [user, setHudData]);
// Listen for real-time HUD updates
useEffect(() => {
if (!user) return;
const unsub1 = on('hud:updated', (data: any) => {
setHudData(data);
});
const unsub2 = on('hud:deduction_applied', (data: any) => {
triggerPulse('red');
toast.error(`⚠️ Deduction Applied: ${data.subCategory} — -${formatEgp(data.amountPiasters)}`, {
duration: 8000,
});
});
const unsub3 = on('hud:bounty_earned', (data: any) => {
triggerPulse('gold');
toast.success(`🎉 Bounty Earned: ${data.cardTitle} — +${formatEgp(data.amountPiasters)}`, {
duration: 8000,
});
});
const unsub4 = on('hud:adjustment_applied', (data: any) => {
const prefix = data.type === 'POSITIVE' ? '+' : '-';
toast.info(`📊 Salary Adjustment: ${data.category}${prefix}${formatEgp(data.amountPiasters)}`, {
duration: 5000,
});
});
return () => {
unsub1();
unsub2();
unsub3();
unsub4();
};
}, [user, on, setHudData, triggerPulse]);
}
\ No newline at end of file
'use client';
import { useEffect } from 'react';
import { useNotificationStore } from '@/stores/notification.store';
import { useSocket } from './use-socket';
import { useAuthStore } from '@/stores/auth.store';
import { apiGet } from '@/lib/api';
export function useNotifications() {
const { on } = useSocket();
const user = useAuthStore((s) => s.user);
const { setNotifications, addNotification, setUnreadCount } = useNotificationStore();
// Load initial notifications
useEffect(() => {
if (!user) return;
apiGet('/notifications', { limit: 50 })
.then((res) => {
if (res.data?.data) {
setNotifications(res.data.data);
} else if (Array.isArray(res.data)) {
setNotifications(res.data);
}
})
.catch((err) => {
console.error('Failed to load notifications:', err);
});
// Get unread count
apiGet('/notifications', { limit: 1 })
.then((res) => {
if (res.meta?.total !== undefined) {
// This is rough - ideally we'd have a dedicated unread count endpoint
}
})
.catch(() => {});
}, [user, setNotifications, setUnreadCount]);
// Listen for real-time notifications
useEffect(() => {
if (!user) return;
const unsub1 = on('notification:new', (notification: any) => {
addNotification(notification);
});
const unsub2 = on('notification:blocking', (notification: any) => {
addNotification({ ...notification, isBlocking: true });
});
return () => {
unsub1();
unsub2();
};
}, [user, on, addNotification]);
}
\ No newline at end of file
'use client';
import { useAuthStore } from '@/stores/auth.store';
import type { Role } from '@/lib/permissions';
export function useRole() {
const user = useAuthStore((s) => s.user);
const role = (user?.role || 'CONTRACTOR') as Role;
return {
role,
isSuperAdmin: role === 'SUPER_ADMIN',
isAdmin: role === 'SUPER_ADMIN' || role === 'ADMIN',
isTeamLead: role === 'TEAM_LEAD',
isContractor: role === 'CONTRACTOR',
hasRole: (...roles: Role[]) => roles.includes(role),
};
}
\ No newline at end of file
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { getSocket, disconnectSocket } from '@/lib/socket';
import { useAuthStore } from '@/stores/auth.store';
import type { Socket } from 'socket.io-client';
export function useSocket() {
const socketRef = useRef<Socket | null>(null);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
if (isAuthenticated) {
socketRef.current = getSocket();
}
return () => {
// Don't disconnect on unmount — socket is shared
};
}, [isAuthenticated]);
const on = useCallback((event: string, handler: (...args: any[]) => void) => {
const socket = socketRef.current || getSocket();
socket.on(event, handler);
return () => {
socket.off(event, handler);
};
}, []);
const emit = useCallback((event: string, ...args: any[]) => {
const socket = socketRef.current || getSocket();
socket.emit(event, ...args);
}, []);
return { on, emit, socket: socketRef.current };
}
\ No newline at end of file
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
interface ApiOptions {
method?: HttpMethod;
body?: any;
headers?: Record<string, string>;
params?: Record<string, string | number | boolean | undefined>;
}
interface ApiResponse<T = any> {
success: boolean;
data: T;
message?: string;
meta?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
let accessToken: string | null = null;
let refreshToken: string | null = null;
let refreshPromise: Promise<void> | null = null;
export function setTokens(access: string, refresh: string): void {
accessToken = access;
refreshToken = refresh;
if (typeof window !== 'undefined') {
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
}
}
export function getAccessToken(): string | null {
if (accessToken) return accessToken;
if (typeof window !== 'undefined') {
accessToken = localStorage.getItem('accessToken');
}
return accessToken;
}
export function getRefreshToken(): string | null {
if (refreshToken) return refreshToken;
if (typeof window !== 'undefined') {
refreshToken = localStorage.getItem('refreshToken');
}
return refreshToken;
}
export function clearTokens(): void {
accessToken = null;
refreshToken = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
}
}
async function refreshAccessToken(): Promise<void> {
const rt = getRefreshToken();
if (!rt) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('No refresh token');
}
try {
const res = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: rt }),
});
if (!res.ok) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Refresh failed');
}
const json = await res.json();
const data = json.data || json;
setTokens(data.accessToken, data.refreshToken);
} catch (err) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw err;
}
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(`${API_BASE_URL}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.append(key, String(value));
}
}
}
return url.toString();
}
export async function api<T = any>(path: string, options: ApiOptions = {}): Promise<ApiResponse<T>> {
const { method = 'GET', body, headers = {}, params } = options;
const token = getAccessToken();
const requestHeaders: Record<string, string> = {
...headers,
};
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`;
}
if (body && !(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json';
}
const url = buildUrl(path, params);
let res = await fetch(url, {
method,
headers: requestHeaders,
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
});
// Handle 401 — try to refresh
if (res.status === 401 && token) {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
try {
await refreshPromise;
} catch {
throw new ApiError('Session expired. Please log in again.', 401);
}
// Retry with new token
const newToken = getAccessToken();
if (newToken) {
requestHeaders['Authorization'] = `Bearer ${newToken}`;
}
res = await fetch(url, {
method,
headers: requestHeaders,
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
});
}
const json = await res.json().catch(() => ({ success: false, message: 'Invalid response' }));
if (!res.ok) {
const message = json.message || json.errors?.join(', ') || `Request failed with status ${res.status}`;
throw new ApiError(message, res.status, json.errors);
}
return json as ApiResponse<T>;
}
export class ApiError extends Error {
status: number;
errors?: string[];
constructor(message: string, status: number, errors?: string[]) {
super(message);
this.name = 'ApiError';
this.status = status;
this.errors = errors;
}
}
// Convenience methods
export const apiGet = <T = any>(path: string, params?: Record<string, any>) =>
api<T>(path, { method: 'GET', params });
export const apiPost = <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'POST', body });
export const apiPut = <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'PUT', body });
export const apiPatch = <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'PATCH', body });
export const apiDelete = <T = any>(path: string, body?: any) =>
api<T>(path, { method: 'DELETE', body });
\ No newline at end of file
import {
format,
formatDistanceToNow,
isToday,
isTomorrow,
isYesterday,
isPast,
differenceInDays,
differenceInHours,
parseISO,
} from 'date-fns';
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'MMM d, yyyy');
}
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'MMM d, yyyy h:mm a');
}
export function formatTime(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'h:mm a');
}
export function formatShortDate(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
if (isToday(d)) return 'Today';
if (isYesterday(d)) return 'Yesterday';
if (isTomorrow(d)) return 'Tomorrow';
return format(d, 'MMM d');
}
export function relativeTime(date: string | Date): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return formatDistanceToNow(d, { addSuffix: true });
}
export function isOverdue(date: string | Date | null | undefined): boolean {
if (!date) return false;
const d = typeof date === 'string' ? parseISO(date) : date;
return isPast(d);
}
export function daysUntil(date: string | Date): number {
const d = typeof date === 'string' ? parseISO(date) : date;
return differenceInDays(d, new Date());
}
export function hoursUntil(date: string | Date): number {
const d = typeof date === 'string' ? parseISO(date) : date;
return differenceInHours(d, new Date());
}
export function getDeadlineUrgency(
date: string | Date | null | undefined,
): 'overdue' | 'today' | 'soon' | 'normal' | 'none' {
if (!date) return 'none';
const d = typeof date === 'string' ? parseISO(date) : date;
if (isPast(d)) return 'overdue';
if (isToday(d)) return 'today';
if (differenceInDays(d, new Date()) <= 2) return 'soon';
return 'normal';
}
export function formatMonthYear(month: number, year: number): string {
return format(new Date(year, month - 1), 'MMMM yyyy');
}
\ No newline at end of file
export type Role = 'SUPER_ADMIN' | 'ADMIN' | 'TEAM_LEAD' | 'CONTRACTOR';
export function canManageBoards(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function canCreateCards(role: Role, boardAllowsContractorCreation: boolean): boolean {
if (role === 'SUPER_ADMIN' || role === 'ADMIN' || role === 'TEAM_LEAD') return true;
return role === 'CONTRACTOR' && boardAllowsContractorCreation;
}
export function canMoveToDone(role: Role): boolean {
return role !== 'CONTRACTOR';
}
export function canAssignCards(role: Role): boolean {
return role !== 'CONTRACTOR';
}
export function canSetBounty(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function canSetDeadline(role: Role): boolean {
return role !== 'CONTRACTOR';
}
export function canManageLabels(role: Role, scope: 'ORGANIZATION' | 'BOARD'): boolean {
if (role === 'SUPER_ADMIN') return true;
if (scope === 'ORGANIZATION') return false;
return role === 'ADMIN' || role === 'TEAM_LEAD';
}
export function canViewSalary(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function canManageDeductions(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function canInitiateDeductions(role: Role): boolean {
return role !== 'CONTRACTOR';
}
export function canApprovePayroll(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function canManageSettings(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function canViewAuditTrail(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function canManageUsers(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function canTerminateUsers(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function canResetPasswords(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function canManageApiKeys(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function canDeleteComments(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function canArchiveCards(role: Role): boolean {
return role !== 'CONTRACTOR';
}
export function canDeleteCards(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
export function isAdmin(role: Role): boolean {
return role === 'SUPER_ADMIN' || role === 'ADMIN';
}
export function isSuperAdmin(role: Role): boolean {
return role === 'SUPER_ADMIN';
}
\ No newline at end of file
import { io, Socket } from 'socket.io-client';
import { getAccessToken } from './api';
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'http://localhost:3001';
let socket: Socket | null = null;
export function getSocket(): Socket {
if (socket?.connected) return socket;
const token = getAccessToken();
socket = io(WS_URL, {
auth: { token },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
autoConnect: true,
});
socket.on('connect', () => {
console.log('[Socket] Connected:', socket?.id);
});
socket.on('disconnect', (reason) => {
console.log('[Socket] Disconnected:', reason);
});
socket.on('connect_error', (err) => {
console.error('[Socket] Connection error:', err.message);
});
socket.on('error', (err) => {
console.error('[Socket] Error:', err);
});
return socket;
}
export function disconnectSocket(): void {
if (socket) {
socket.removeAllListeners();
socket.disconnect();
socket = null;
}
}
export function joinRoom(room: string): void {
const s = getSocket();
s.emit('join', room);
}
export function leaveRoom(room: string): void {
const s = getSocket();
s.emit('leave', room);
}
\ No newline at end of file
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatEgp(piasters: number): string {
const egp = piasters / 100;
return new Intl.NumberFormat('en-EG', {
style: 'currency',
currency: 'EGP',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(egp);
}
export function piasterToEgp(piasters: number): number {
return piasters / 100;
}
export function egpToPiasters(egp: number): number {
return Math.round(egp * 100);
}
export function formatNumber(n: number): string {
return new Intl.NumberFormat('en-US').format(n);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + '…';
}
export function getInitials(firstName: string, lastName: string): string {
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getHealthStatus(
deductionCount: number,
retentionPercent: number,
): 'HEALTHY' | 'WARNING' | 'CRITICAL' {
if (deductionCount > 3 || retentionPercent < 60) return 'CRITICAL';
if (deductionCount >= 2 || retentionPercent < 80) return 'WARNING';
return 'HEALTHY';
}
export function getBarColor(retentionPercent: number): string {
if (retentionPercent > 100) return 'bg-hud-gold';
if (retentionPercent >= 80) return 'bg-hud-green';
if (retentionPercent >= 60) return 'bg-hud-yellow';
return 'bg-hud-red';
}
\ No newline at end of file
import { create } from 'zustand';
import { api, setTokens, clearTokens, getAccessToken } from '@/lib/api';
interface User {
id: string;
email: string;
username: string;
firstName: string;
lastName: string;
displayName: string | null;
avatar: string | null;
role: 'SUPER_ADMIN' | 'ADMIN' | 'TEAM_LEAD' | 'CONTRACTOR';
status: string;
forcePasswordChange: boolean;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (login: string, password: string) => Promise<void>;
logout: () => Promise<void>;
loadUser: () => void;
setUser: (user: User) => void;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (login: string, password: string) => {
set({ isLoading: true, error: null });
try {
const res = await api('/auth/login', {
method: 'POST',
body: { login, password },
});
const data = res.data;
setTokens(data.accessToken, data.refreshToken);
const user: User = data.user;
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user));
}
set({ user, isAuthenticated: true, isLoading: false, error: null });
} catch (err: any) {
set({ isLoading: false, error: err.message || 'Login failed' });
throw err;
}
},
logout: async () => {
try {
const token = getAccessToken();
if (token) {
await api('/auth/logout', { method: 'POST' }).catch(() => {});
}
} finally {
clearTokens();
set({ user: null, isAuthenticated: false, error: null });
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
},
loadUser: () => {
if (typeof window === 'undefined') return;
const token = getAccessToken();
const savedUser = localStorage.getItem('user');
if (token && savedUser) {
try {
const user = JSON.parse(savedUser) as User;
set({ user, isAuthenticated: true });
} catch {
clearTokens();
set({ user: null, isAuthenticated: false });
}
} else {
set({ user: null, isAuthenticated: false });
}
},
setUser: (user: User) => {
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user));
}
set({ user, isAuthenticated: true });
},
clearError: () => set({ error: null }),
}));
\ No newline at end of file
import { create } from 'zustand';
interface HudLineItem {
id: string;
type: 'SALARY' | 'BOUNTY' | 'DEDUCTION' | 'ADJUSTMENT';
label: string;
amountPiasters: number;
entityType?: string;
entityId?: string;
createdAt: string;
}
interface HudState {
actualSalaryPiasters: number;
liveSalaryPiasters: number;
totalBountiesPiasters: number;
totalDeductionsPiasters: number;
totalPositiveAdjustmentsPiasters: number;
totalNegativeAdjustmentsPiasters: number;
deductionCount: number;
bountyCount: number;
currentStreak: number;
bestStreak: number;
rank: number | null;
totalContractors: number | null;
month: number;
year: number;
lineItems: HudLineItem[];
isLoaded: boolean;
pulseAnimation: 'none' | 'red' | 'gold';
setHudData: (data: Partial<HudState>) => void;
triggerPulse: (type: 'red' | 'gold') => void;
reset: () => void;
}
export const useHudStore = create<HudState>((set) => ({
actualSalaryPiasters: 0,
liveSalaryPiasters: 0,
totalBountiesPiasters: 0,
totalDeductionsPiasters: 0,
totalPositiveAdjustmentsPiasters: 0,
totalNegativeAdjustmentsPiasters: 0,
deductionCount: 0,
bountyCount: 0,
currentStreak: 0,
bestStreak: 0,
rank: null,
totalContractors: null,
month: new Date().getMonth() + 1,
year: new Date().getFullYear(),
lineItems: [],
isLoaded: false,
pulseAnimation: 'none',
setHudData: (data) => set((state) => ({ ...state, ...data, isLoaded: true })),
triggerPulse: (type) => {
set({ pulseAnimation: type });
setTimeout(() => set({ pulseAnimation: 'none' }), 3000);
},
reset: () =>
set({
actualSalaryPiasters: 0,
liveSalaryPiasters: 0,
totalBountiesPiasters: 0,
totalDeductionsPiasters: 0,
totalPositiveAdjustmentsPiasters: 0,
totalNegativeAdjustmentsPiasters: 0,
deductionCount: 0,
bountyCount: 0,
currentStreak: 0,
bestStreak: 0,
rank: null,
totalContractors: null,
lineItems: [],
isLoaded: false,
pulseAnimation: 'none',
}),
}));
\ No newline at end of file
import { create } from 'zustand';
interface Notification {
id: string;
type: 'BLOCKING' | 'IMPORTANT' | 'INFORMATIONAL';
category: string;
title: string;
message: string;
actionUrl?: string;
isRead: boolean;
isBlocking: boolean;
acknowledgedAt: string | null;
entityType?: string;
entityId?: string;
createdAt: string;
}
interface NotificationState {
notifications: Notification[];
unreadCount: number;
blockingQueue: Notification[];
isDropdownOpen: boolean;
setNotifications: (notifications: Notification[]) => void;
addNotification: (notification: Notification) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
acknowledgeBlocking: (id: string) => void;
setUnreadCount: (count: number) => void;
toggleDropdown: () => void;
closeDropdown: () => void;
reset: () => void;
}
export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0,
blockingQueue: [],
isDropdownOpen: false,
setNotifications: (notifications) => {
const unreadCount = notifications.filter((n) => !n.isRead).length;
const blockingQueue = notifications.filter(
(n) => n.isBlocking && !n.acknowledgedAt,
);
set({ notifications, unreadCount, blockingQueue });
},
addNotification: (notification) => {
set((state) => {
const notifications = [notification, ...state.notifications];
const unreadCount = state.unreadCount + (notification.isRead ? 0 : 1);
const blockingQueue =
notification.isBlocking && !notification.acknowledgedAt
? [...state.blockingQueue, notification]
: state.blockingQueue;
return { notifications, unreadCount, blockingQueue };
});
},
markAsRead: (id) => {
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, isRead: true } : n,
),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
},
markAllAsRead: () => {
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
unreadCount: 0,
}));
},
acknowledgeBlocking: (id) => {
set((state) => ({
blockingQueue: state.blockingQueue.filter((n) => n.id !== id),
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, acknowledgedAt: new Date().toISOString(), isRead: true } : n,
),
}));
},
setUnreadCount: (count) => set({ unreadCount: count }),
toggleDropdown: () => set((state) => ({ isDropdownOpen: !state.isDropdownOpen })),
closeDropdown: () => set({ isDropdownOpen: false }),
reset: () =>
set({
notifications: [],
unreadCount: 0,
blockingQueue: [],
isDropdownOpen: false,
}),
}));
\ No newline at end of file
import { create } from 'zustand';
interface ThemeState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
}));
\ No newline at end of file
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: { '2xl': '1400px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
border: 'hsl(var(--sidebar-border))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
},
hud: {
green: '#22c55e',
yellow: '#eab308',
red: '#ef4444',
gold: '#f59e0b',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'hud-pulse-red': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(239, 68, 68, 0)' },
'50%': { boxShadow: '0 0 20px 4px rgba(239, 68, 68, 0.4)' },
},
'hud-pulse-gold': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(245, 158, 11, 0)' },
'50%': { boxShadow: '0 0 20px 4px rgba(245, 158, 11, 0.4)' },
},
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-in-right': {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
},
},
animation: {
'hud-pulse-red': 'hud-pulse-red 3s ease-in-out',
'hud-pulse-gold': 'hud-pulse-gold 3s ease-in-out',
'fade-in': 'fade-in 0.2s ease-out',
'slide-in-right': 'slide-in-right 0.3s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;
\ No newline at end of file
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../shared/src/*"]
},
"target": "ES2017",
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment