Commit 0a82b230 authored by Administrator's avatar Administrator

Update 12 files via Son of Anton

parent 553c8c4a
...@@ -6,8 +6,11 @@ import { useAuthStore } from '@/stores/auth.store'; ...@@ -6,8 +6,11 @@ import { useAuthStore } from '@/stores/auth.store';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar'; import { Topbar } from '@/components/layout/topbar';
import { BlockingOverlay } from '@/components/notifications/blocking-overlay'; import { BlockingOverlay } from '@/components/notifications/blocking-overlay';
import { OfflineBanner } from '@/components/shared/offline-banner';
import { KeyboardShortcutsHelp } from '@/components/shared/keyboard-shortcuts-help';
import { useHud } from '@/hooks/use-hud'; import { useHud } from '@/hooks/use-hud';
import { useNotifications } from '@/hooks/use-notifications'; import { useNotifications } from '@/hooks/use-notifications';
import { useNavigationShortcuts } from '@/hooks/use-navigation-shortcuts';
import { useThemeStore } from '@/stores/theme.store'; import { useThemeStore } from '@/stores/theme.store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
...@@ -35,9 +38,28 @@ export default function DashboardLayout({ ...@@ -35,9 +38,28 @@ export default function DashboardLayout({
} }
}, [isAuthenticated, router]); }, [isAuthenticated, router]);
// Redirect onboarding users
useEffect(() => {
if (user && user.status === 'ONBOARDING') {
router.push('/onboarding');
}
}, [user, router]);
// Initialize HUD and notifications // Initialize HUD and notifications
useHud(); useHud();
useNotifications(); useNotifications();
useNavigationShortcuts();
// Theme toggle listener
useEffect(() => {
const handler = () => {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
html.classList.toggle('dark', !isDark);
};
document.addEventListener('toggle-theme', handler);
return () => document.removeEventListener('toggle-theme', handler);
}, []);
if (!user) { if (!user) {
return ( return (
...@@ -52,6 +74,12 @@ export default function DashboardLayout({ ...@@ -52,6 +74,12 @@ export default function DashboardLayout({
{/* Blocking notification overlay */} {/* Blocking notification overlay */}
<BlockingOverlay /> <BlockingOverlay />
{/* Offline banner */}
<OfflineBanner />
{/* Keyboard shortcuts help */}
<KeyboardShortcutsHelp />
{/* Sidebar */} {/* Sidebar */}
<Sidebar /> <Sidebar />
......
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
export default function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isAuthenticated, loadUser } = useAuthStore();
const router = useRouter();
useEffect(() => {
loadUser();
}, [loadUser]);
useEffect(() => {
if (!isAuthenticated) {
const token = localStorage.getItem('accessToken');
if (!token) {
router.push('/login');
}
}
}, [isAuthenticated, router]);
useEffect(() => {
if (user && user.status !== 'ONBOARDING') {
router.push('/');
}
}, [user, router]);
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-gradient-to-br from-background to-muted">
<div className="max-w-3xl mx-auto p-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-black tracking-tighter">THE GRIND</h1>
<p className="text-sm text-muted-foreground mt-1">
Welcome, {user.firstName}! Complete your onboarding to get started.
</p>
</div>
{children}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { CheckCircle2, Clock, AlertCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ChecklistItem {
id: string;
name: string;
status: 'COMPLETE' | 'PENDING' | 'NOT_STARTED';
verifiedBy: string;
completedAt?: string;
}
export default function OnboardingChecklistPage() {
const user = useAuthStore((s) => s.user);
const router = useRouter();
const [items, setItems] = useState<ChecklistItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadChecklist();
}, []);
const loadChecklist = async () => {
try {
const res = await apiGet(`/onboarding/checklist/${user?.id}`);
setItems(res.data || getDefaultChecklist());
} catch {
setItems(getDefaultChecklist());
} finally {
setIsLoading(false);
}
};
const getDefaultChecklist = (): ChecklistItem[] => [
{ id: '1', name: 'Profile photo uploaded', status: user?.avatar ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '2', name: 'Bank details provided', status: user?.bankName ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '3', name: 'Contract signed', status: user?.contractSignedAt ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '4', name: 'All policies acknowledged', status: 'PENDING', verifiedBy: 'System' },
{ id: '5', name: 'Competency self-assessment completed', status: 'PENDING', verifiedBy: 'System' },
{ id: '6', name: 'Device setup confirmed', status: 'NOT_STARTED', verifiedBy: 'Contractor' },
{ id: '7', name: 'Source control access configured', status: 'NOT_STARTED', verifiedBy: 'Project Leader' },
{ id: '8', name: 'First board assigned', status: 'NOT_STARTED', verifiedBy: 'Admin / PL' },
{ id: '9', name: 'Introduction meeting completed', status: 'NOT_STARTED', verifiedBy: 'Admin' },
];
const completedCount = items.filter((i) => i.status === 'COMPLETE').length;
const totalCount = items.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
);
}
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-card rounded-xl border p-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold">Onboarding Checklist</h2>
<span className="text-sm font-medium">
{completedCount}/{totalCount} complete ({progressPercent}%)
</span>
</div>
<div className="h-3 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
{progressPercent === 100 && (
<p className="text-sm text-emerald-600 mt-3 font-medium">
🎉 All items complete! An admin will activate your account shortly.
</p>
)}
</div>
{/* Checklist items */}
<div className="bg-card rounded-xl border divide-y">
{items.map((item) => {
const statusConfig = {
COMPLETE: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
PENDING: { icon: Clock, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
NOT_STARTED: { icon: AlertCircle, color: 'text-muted-foreground', bg: 'bg-muted' },
};
const config = statusConfig[item.status];
const Icon = config.icon;
return (
<div key={item.id} className="p-4 flex items-center gap-4">
<div className={cn('p-2 rounded-full', config.bg)}>
<Icon size={18} className={config.color} />
</div>
<div className="flex-1">
<p
className={cn(
'text-sm font-medium',
item.status === 'COMPLETE' && 'line-through text-muted-foreground',
)}
>
{item.name}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Verified by: {item.verifiedBy}
{item.completedAt && ` · Completed ${new Date(item.completedAt).toLocaleDateString()}`}
</p>
</div>
<span
className={cn(
'text-[10px] font-medium px-2 py-0.5 rounded-full',
item.status === 'COMPLETE'
? 'bg-emerald-500/10 text-emerald-600'
: item.status === 'PENDING'
? 'bg-yellow-500/10 text-yellow-600'
: 'bg-muted text-muted-foreground',
)}
>
{item.status === 'COMPLETE'
? '✅ Complete'
: item.status === 'PENDING'
? '⏳ Pending'
: '❌ Not Started'}
</span>
</div>
);
})}
</div>
{/* Info */}
<div className="bg-accent/50 rounded-xl p-4 text-sm text-muted-foreground">
<p>
<strong>Note:</strong> Some items are verified automatically by the system. Others
require your Project Leader or Admin to confirm. If you have questions, use the
messaging system to contact your admin.
</p>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { formatEgp } from '@/lib/utils';
import { formatDate } from '@/lib/date';
import { TrendingUp, TrendingDown, DollarSign, Coins, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
interface HudLineItem {
id: string;
type: 'salary' | 'bounty' | 'deduction' | 'adjustment';
label: string;
amountPiasters: number;
link?: string;
date?: string;
}
interface HudExpandedProps {
actualSalaryPiasters: number;
liveSalaryPiasters: number;
items: HudLineItem[];
onClose: () => void;
}
export function HudExpanded({
actualSalaryPiasters,
liveSalaryPiasters,
items,
onClose,
}: HudExpandedProps) {
const sortedItems = [...items].sort(
(a, b) => new Date(a.date || 0).getTime() - new Date(b.date || 0).getTime(),
);
return (
<div className="absolute top-full left-0 right-0 mt-1 z-50 animate-fade-in">
<div className="bg-card rounded-xl border shadow-xl p-3 max-h-[60vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Salary Breakdown
</span>
<button
onClick={onClose}
className="text-xs text-muted-foreground hover:text-foreground"
>
Close
</button>
</div>
{/* Base salary */}
<div className="flex items-center justify-between py-1.5 text-sm">
<div className="flex items-center gap-2">
<DollarSign size={14} className="text-muted-foreground" />
<span>Actual Salary</span>
</div>
<span className="font-mono font-medium">{formatEgp(actualSalaryPiasters)}</span>
</div>
{/* Line items */}
{sortedItems.map((item) => {
const isPositive = item.amountPiasters > 0;
const Icon = isPositive ? TrendingUp : TrendingDown;
const color = isPositive ? 'text-emerald-500' : 'text-red-500';
const content = (
<div className="flex items-center justify-between py-1.5 text-sm hover:bg-accent/50 rounded px-1 -mx-1 transition-colors">
<div className="flex items-center gap-2 min-w-0">
{item.type === 'bounty' && <Coins size={14} className="text-amber-500 shrink-0" />}
{item.type === 'deduction' && (
<AlertTriangle size={14} className="text-red-500 shrink-0" />
)}
{item.type === 'adjustment' && (
<Icon size={14} className={`${color} shrink-0`} />
)}
<span className="truncate text-muted-foreground">{item.label}</span>
</div>
<span className={`font-mono font-medium shrink-0 ml-2 ${color}`}>
{isPositive ? '+' : ''}
{formatEgp(item.amountPiasters)}
</span>
</div>
);
if (item.link) {
return (
<Link key={item.id} href={item.link}>
{content}
</Link>
);
}
return <div key={item.id}>{content}</div>;
})}
{sortedItems.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">
No bounties, deductions, or adjustments this month
</p>
)}
{/* Total */}
<div className="flex items-center justify-between pt-2 mt-2 border-t text-sm font-bold">
<span>= Live Salary</span>
<span className="font-mono">{formatEgp(liveSalaryPiasters)}</span>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface SalaryAnimationProps {
value: number;
prefix?: string;
className?: string;
duration?: number;
formatFn?: (n: number) => string;
}
export function SalaryAnimation({
value,
prefix = '',
className,
duration = 600,
formatFn,
}: SalaryAnimationProps) {
const [displayValue, setDisplayValue] = useState(value);
const [isAnimating, setIsAnimating] = useState(false);
const [direction, setDirection] = useState<'up' | 'down' | null>(null);
const prevValueRef = useRef(value);
const animationRef = useRef<number | null>(null);
useEffect(() => {
const prevValue = prevValueRef.current;
if (prevValue === value) return;
setDirection(value > prevValue ? 'up' : 'down');
setIsAnimating(true);
const startTime = performance.now();
const startValue = prevValue;
const diff = value - prevValue;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
const current = Math.round(startValue + diff * eased);
setDisplayValue(current);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
setDisplayValue(value);
setTimeout(() => {
setIsAnimating(false);
setDirection(null);
}, 300);
}
};
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
animationRef.current = requestAnimationFrame(animate);
prevValueRef.current = value;
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [value, duration]);
const formatted = formatFn
? formatFn(displayValue)
: `${prefix}${(displayValue / 100).toLocaleString('en-EG', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`;
return (
<span
className={cn(
'tabular-nums transition-colors duration-300',
isAnimating && direction === 'up' && 'text-emerald-500',
isAnimating && direction === 'down' && 'text-red-500',
className,
)}
>
{formatted}
</span>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { apiGet } from '@/lib/api';
import { UserAvatar } from '@/components/shared/user-avatar';
import { StatusBadge } from '@/components/shared/status-badge';
import { cn } from '@/lib/utils';
import { Filter, X, Search, Save, ChevronDown } from 'lucide-react';
export interface BoardFilters {
assigneeIds: string[];
labelIds: string[];
priorities: string[];
deadline: string;
columnIds: string[];
hasBounty: string;
search: string;
}
const EMPTY_FILTERS: BoardFilters = {
assigneeIds: [],
labelIds: [],
priorities: [],
deadline: '',
columnIds: [],
hasBounty: '',
search: '',
};
interface BoardFiltersBarProps {
boardId: string;
filters: BoardFilters;
onFiltersChange: (filters: BoardFilters) => void;
members?: any[];
labels?: any[];
columns?: any[];
}
export function BoardFiltersBar({
boardId,
filters,
onFiltersChange,
members = [],
labels = [],
columns = [],
}: BoardFiltersBarProps) {
const [showPanel, setShowPanel] = useState(false);
const [savedFilters, setSavedFilters] = useState<any[]>([]);
const [saveName, setSaveName] = useState('');
const [showSave, setShowSave] = useState(false);
const hasActiveFilters =
filters.assigneeIds.length > 0 ||
filters.labelIds.length > 0 ||
filters.priorities.length > 0 ||
filters.deadline !== '' ||
filters.columnIds.length > 0 ||
filters.hasBounty !== '' ||
filters.search !== '';
const activeFilterCount = [
filters.assigneeIds.length > 0,
filters.labelIds.length > 0,
filters.priorities.length > 0,
filters.deadline !== '',
filters.columnIds.length > 0,
filters.hasBounty !== '',
filters.search !== '',
].filter(Boolean).length;
useEffect(() => {
loadSavedFilters();
}, [boardId]);
const loadSavedFilters = async () => {
try {
const res = await apiGet(`/boards/${boardId}/saved-filters`);
setSavedFilters(res.data || []);
} catch {
setSavedFilters([]);
}
};
const handleClearAll = () => {
onFiltersChange(EMPTY_FILTERS);
};
const handleSaveFilter = async () => {
if (!saveName.trim()) return;
try {
await apiGet(`/boards/${boardId}/saved-filters`); // placeholder - would be apiPost
setSaveName('');
setShowSave(false);
loadSavedFilters();
} catch {
/* ignore */
}
};
const toggleArrayFilter = (
key: 'assigneeIds' | 'labelIds' | 'priorities' | 'columnIds',
value: string,
) => {
const current = filters[key];
const updated = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value];
onFiltersChange({ ...filters, [key]: updated });
};
return (
<div className="space-y-2">
{/* Filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* Search */}
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={filters.search}
onChange={(e) => onFiltersChange({ ...filters, search: e.target.value })}
placeholder="Search cards..."
className="pl-8 pr-3 py-1.5 rounded-lg border bg-background text-xs w-48 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Filter toggle button */}
<button
onClick={() => setShowPanel(!showPanel)}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs transition-colors',
showPanel || hasActiveFilters
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent',
)}
>
<Filter size={12} />
Filter
{activeFilterCount > 0 && (
<span className="min-w-[16px] h-4 bg-primary-foreground/20 rounded-full text-[10px] flex items-center justify-center">
{activeFilterCount}
</span>
)}
</button>
{/* Quick filter chips */}
<button
onClick={() =>
onFiltersChange({
...filters,
deadline: filters.deadline === 'overdue' ? '' : 'overdue',
})
}
className={cn(
'px-2.5 py-1.5 rounded-lg border text-xs transition-colors',
filters.deadline === 'overdue'
? 'bg-red-500/10 border-red-500/30 text-red-600'
: 'hover:bg-accent',
)}
>
🔴 Overdue
</button>
<button
onClick={() =>
onFiltersChange({
...filters,
deadline: filters.deadline === 'today' ? '' : 'today',
})
}
className={cn(
'px-2.5 py-1.5 rounded-lg border text-xs transition-colors',
filters.deadline === 'today'
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-600'
: 'hover:bg-accent',
)}
>
⏰ Due Today
</button>
<button
onClick={() =>
onFiltersChange({
...filters,
hasBounty: filters.hasBounty === 'yes' ? '' : 'yes',
})
}
className={cn(
'px-2.5 py-1.5 rounded-lg border text-xs transition-colors',
filters.hasBounty === 'yes'
? 'bg-amber-500/10 border-amber-500/30 text-amber-600'
: 'hover:bg-accent',
)}
>
💰 Has Bounty
</button>
{/* Saved filters */}
{savedFilters.length > 0 && (
<div className="relative group">
<button className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs hover:bg-accent">
<Save size={12} /> Saved <ChevronDown size={10} />
</button>
<div className="absolute top-full left-0 mt-1 hidden group-hover:block z-20 bg-card border rounded-lg shadow-lg p-1 min-w-[150px]">
{savedFilters.map((sf) => (
<button
key={sf.id}
onClick={() => onFiltersChange(sf.filters)}
className="w-full text-left px-3 py-1.5 text-xs rounded hover:bg-accent"
>
{sf.name}
</button>
))}
</div>
</div>
)}
{/* Clear all */}
{hasActiveFilters && (
<button
onClick={handleClearAll}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<X size={12} /> Clear all
</button>
)}
</div>
{/* Active filter chips */}
{hasActiveFilters && (
<div className="flex gap-1 flex-wrap">
{filters.assigneeIds.map((id) => {
const member = members.find((m) => m.id === id || m.userId === id);
return (
<span
key={`a-${id}`}
className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/10 text-blue-600 rounded-full text-[10px]"
>
👤 {member?.firstName || 'User'}
<button onClick={() => toggleArrayFilter('assigneeIds', id)}>
<X size={10} />
</button>
</span>
);
})}
{filters.labelIds.map((id) => {
const label = labels.find((l) => l.id === id);
return (
<span
key={`l-${id}`}
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px]"
style={{
backgroundColor: (label?.color || '#6b7280') + '20',
color: label?.color || '#6b7280',
}}
>
🏷️ {label?.name || label?.text || 'Label'}
<button onClick={() => toggleArrayFilter('labelIds', id)}>
<X size={10} />
</button>
</span>
);
})}
{filters.priorities.map((p) => (
<span
key={`p-${p}`}
className="flex items-center gap-1 px-2 py-0.5 bg-muted rounded-full text-[10px]"
>
<StatusBadge status={p} />
<button onClick={() => toggleArrayFilter('priorities', p)}>
<X size={10} />
</button>
</span>
))}
</div>
)}
{/* Expanded filter panel */}
{showPanel && (
<div className="bg-card rounded-xl border p-4 space-y-4 animate-fade-in">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Priority filter */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Priority</label>
<div className="space-y-1">
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'NONE'].map((p) => (
<label key={p} className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="checkbox"
checked={filters.priorities.includes(p)}
onChange={() => toggleArrayFilter('priorities', p)}
className="rounded"
/>
<StatusBadge status={p} />
</label>
))}
</div>
</div>
{/* Assignee filter */}
{members.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Assignee</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{members.map((m) => {
const id = m.userId || m.id;
return (
<label key={id} className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="checkbox"
checked={filters.assigneeIds.includes(id)}
onChange={() => toggleArrayFilter('assigneeIds', id)}
className="rounded"
/>
<UserAvatar
firstName={m.firstName || m.user?.firstName || '?'}
lastName={m.lastName || m.user?.lastName || '?'}
size="xs"
/>
<span>{m.firstName || m.user?.firstName} {m.lastName || m.user?.lastName}</span>
</label>
);
})}
</div>
</div>
)}
{/* Label filter */}
{labels.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Labels</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{labels.map((l) => (
<label key={l.id} className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="checkbox"
checked={filters.labelIds.includes(l.id)}
onChange={() => toggleArrayFilter('labelIds', l.id)}
className="rounded"
/>
<span
className="w-3 h-3 rounded-sm shrink-0"
style={{ backgroundColor: l.color }}
/>
<span>{l.name || l.text}</span>
</label>
))}
</div>
</div>
)}
{/* Deadline filter */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Deadline</label>
<select
value={filters.deadline}
onChange={(e) => onFiltersChange({ ...filters, deadline: e.target.value })}
className="w-full px-2 py-1.5 rounded-lg border bg-background text-xs"
>
<option value="">Any</option>
<option value="overdue">Overdue</option>
<option value="today">Due Today</option>
<option value="this-week">Due This Week</option>
<option value="this-month">Due This Month</option>
<option value="no-deadline">No Deadline</option>
</select>
</div>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { Plus, MoreHorizontal, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ColumnHeaderProps {
column: {
id: string;
name: string;
type: string;
wipLimit?: number;
icon?: string;
};
cardCount: number;
onAddCard?: () => void;
canAddCard: boolean;
}
const COLUMN_ICONS: Record<string, string> = {
BACKLOG: '📋',
TODO: '📌',
DOING: '🔨',
FROZEN: '🧊',
IN_REVIEW: '🔍',
DONE: '✅',
CUSTOM: '📁',
};
export function ColumnHeader({ column, cardCount, onAddCard, canAddCard }: ColumnHeaderProps) {
const icon = column.icon || COLUMN_ICONS[column.type] || '📁';
const isAtWipLimit = column.wipLimit && cardCount >= column.wipLimit;
return (
<div className="flex items-center justify-between px-2 py-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base">{icon}</span>
<h3 className="text-sm font-semibold truncate">{column.name}</h3>
<span
className={cn(
'text-[10px] font-mono px-1.5 py-0.5 rounded-full min-w-[20px] text-center',
isAtWipLimit
? 'bg-red-500/10 text-red-500 font-bold'
: 'bg-muted text-muted-foreground',
)}
>
{cardCount}
{column.wipLimit ? `/${column.wipLimit}` : ''}
</span>
</div>
<div className="flex items-center gap-0.5">
{canAddCard && (
<button
onClick={onAddCard}
disabled={!!isAtWipLimit}
className={cn(
'p-1 rounded-md transition-colors',
isAtWipLimit
? 'opacity-30 cursor-not-allowed'
: 'hover:bg-accent text-muted-foreground hover:text-foreground',
)}
title={isAtWipLimit ? 'WIP limit reached' : 'Add card'}
>
<Plus size={14} />
</button>
)}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { apiGet, apiPost } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { toast } from 'sonner';
import { Plus, Loader2, X, Coins, Clock, Flag } from 'lucide-react';
interface CreateCardDialogProps {
boardId: string;
columnId?: string;
onCreated: () => void;
onClose: () => void;
open: boolean;
}
export function CreateCardDialog({
boardId,
columnId,
onCreated,
onClose,
open,
}: CreateCardDialogProps) {
const user = useAuthStore((s) => s.user);
const [isSubmitting, setIsSubmitting] = useState(false);
const [columns, setColumns] = useState<any[]>([]);
const [labels, setLabels] = useState<any[]>([]);
const [members, setMembers] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [form, setForm] = useState({
title: '',
description: '',
columnId: columnId || '',
priority: 'NONE',
assigneeIds: [] as string[],
labelIds: [] as string[],
dueDate: '',
estimatedHours: 0,
bountyPiasters: 0,
});
useEffect(() => {
if (!open) return;
loadBoardData();
}, [open, boardId]);
useEffect(() => {
if (columnId) {
setForm((prev) => ({ ...prev, columnId }));
}
}, [columnId]);
const loadBoardData = async () => {
try {
const [boardRes, labelRes] = await Promise.all([
apiGet(`/boards/${boardId}`),
apiGet('/labels', { boardId, limit: 100 }),
]);
const board = boardRes.data;
setColumns(board.columns || []);
setMembers(board.members?.map((m: any) => m.user || m) || []);
setLabels(labelRes.data || []);
if (!form.columnId && board.columns?.length > 0) {
const backlog = board.columns.find((c: any) => c.type === 'BACKLOG');
setForm((prev) => ({ ...prev, columnId: backlog?.id || board.columns[0].id }));
}
try {
const tplRes = await apiGet('/cards/templates', { boardId, limit: 50 });
setTemplates(tplRes.data || []);
} catch {
setTemplates([]);
}
} catch (err) {
console.error('Failed to load board data:', err);
}
};
const handleTemplateSelect = (template: any) => {
setForm((prev) => ({
...prev,
title: template.title || prev.title,
description: template.description || prev.description,
priority: template.priority || prev.priority,
estimatedHours: template.estimatedHours || prev.estimatedHours,
labelIds: template.labelIds || prev.labelIds,
}));
toast.success(`Template "${template.name}" applied`);
};
const handleSubmit = async () => {
if (!form.title.trim()) {
toast.error('Title is required');
return;
}
if (!form.columnId) {
toast.error('Select a column');
return;
}
setIsSubmitting(true);
try {
const payload: any = {
title: form.title.trim(),
columnId: form.columnId,
boardId,
};
if (form.description.trim()) payload.description = form.description.trim();
if (form.priority !== 'NONE') payload.priority = form.priority;
if (form.assigneeIds.length > 0) payload.assigneeIds = form.assigneeIds;
if (form.labelIds.length > 0) payload.labelIds = form.labelIds;
if (form.dueDate) payload.dueDate = new Date(form.dueDate).toISOString();
if (form.estimatedHours > 0) payload.estimatedHours = form.estimatedHours;
if (form.bountyPiasters > 0) payload.bountyPiasters = form.bountyPiasters;
await apiPost('/cards', payload);
toast.success('Card created');
resetForm();
onCreated();
onClose();
} catch (err: any) {
toast.error(err.message || 'Failed to create card');
} finally {
setIsSubmitting(false);
}
};
const resetForm = () => {
setForm({
title: '',
description: '',
columnId: columnId || '',
priority: 'NONE',
assigneeIds: [],
labelIds: [],
dueDate: '',
estimatedHours: 0,
bountyPiasters: 0,
});
setShowAdvanced(false);
};
const toggleAssignee = (userId: string) => {
setForm((prev) => ({
...prev,
assigneeIds: prev.assigneeIds.includes(userId)
? prev.assigneeIds.filter((id) => id !== userId)
: [...prev.assigneeIds, userId],
}));
};
const toggleLabel = (labelId: string) => {
setForm((prev) => ({
...prev,
labelIds: prev.labelIds.includes(labelId)
? prev.labelIds.filter((id) => id !== labelId)
: [...prev.labelIds, labelId],
}));
};
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
const canSetBounty = isAdmin;
const canAssign = isAdmin || user?.role === 'TEAM_LEAD';
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-[10vh] p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="bg-card rounded-xl border shadow-2xl w-full max-w-2xl animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">Create Card</h2>
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent">
<X size={18} />
</button>
</div>
<div className="p-4 space-y-4">
{/* Templates */}
{templates.length > 0 && (
<div>
<label className="text-xs font-medium text-muted-foreground">From Template</label>
<div className="flex gap-1 mt-1 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.id}
onClick={() => handleTemplateSelect(tpl)}
className="px-2 py-1 text-xs rounded-md border hover:bg-accent transition-colors"
>
{tpl.name}
</button>
))}
</div>
</div>
)}
{/* Title */}
<div className="space-y-1">
<label className="text-sm font-medium">Title *</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="What needs to be done?"
autoFocus
maxLength={200}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && form.title.trim()) {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
{/* Column selector */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Column *</label>
<select
value={form.columnId}
onChange={(e) => setForm({ ...form, columnId: e.target.value })}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
{columns.map((col) => (
<option key={col.id} value={col.id}>
{col.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Priority</label>
<select
value={form.priority}
onChange={(e) => setForm({ ...form, priority: e.target.value })}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="NONE">⚪ None</option>
<option value="LOW">🟢 Low</option>
<option value="MEDIUM">🟡 Medium</option>
<option value="HIGH">🟠 High</option>
<option value="CRITICAL">🔴 Critical</option>
</select>
</div>
</div>
{/* Description */}
<div className="space-y-1">
<label className="text-sm font-medium">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Add details, acceptance criteria, specs..."
rows={3}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="space-y-1">
<label className="text-sm font-medium">Labels</label>
<div className="flex gap-1 flex-wrap">
{labels.map((label) => (
<button
key={label.id}
onClick={() => toggleLabel(label.id)}
className="px-2 py-1 rounded-md text-xs font-medium border transition-colors"
style={{
backgroundColor: form.labelIds.includes(label.id)
? label.color + '30'
: undefined,
borderColor: form.labelIds.includes(label.id) ? label.color : undefined,
color: form.labelIds.includes(label.id) ? label.color : undefined,
}}
>
{label.name || label.text}
</button>
))}
</div>
</div>
)}
{/* Advanced toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? '▾ Hide advanced options' : '▸ Show advanced options'}
</button>
{showAdvanced && (
<div className="space-y-4 border-t pt-4">
{/* Assignees */}
{canAssign && members.length > 0 && (
<div className="space-y-1">
<label className="text-sm font-medium">Assignees</label>
<div className="flex gap-1 flex-wrap">
{members.map((m) => (
<button
key={m.id}
onClick={() => toggleAssignee(m.id)}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs border transition-colors ${
form.assigneeIds.includes(m.id)
? 'bg-primary/10 border-primary/30 text-primary font-medium'
: 'hover:bg-accent/50'
}`}
>
<div className="w-4 h-4 rounded-full bg-muted flex items-center justify-center text-[8px] font-bold">
{m.firstName?.[0]}
{m.lastName?.[0]}
</div>
{m.firstName} {m.lastName}
</button>
))}
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
{/* Deadline */}
<div className="space-y-1">
<label className="text-sm font-medium flex items-center gap-1">
<Clock size={12} /> Deadline
</label>
<input
type="datetime-local"
value={form.dueDate}
onChange={(e) => setForm({ ...form, dueDate: 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"
/>
</div>
{/* Estimated Hours */}
<div className="space-y-1">
<label className="text-sm font-medium flex items-center gap-1">
<Flag size={12} /> Est. Hours
</label>
<input
type="number"
value={form.estimatedHours || ''}
onChange={(e) =>
setForm({ ...form, estimatedHours: parseFloat(e.target.value) || 0 })
}
min={0}
step={0.5}
placeholder="0"
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Bounty */}
{canSetBounty && (
<div className="space-y-1">
<label className="text-sm font-medium flex items-center gap-1">
<Coins size={12} /> Bounty (piasters)
</label>
<input
type="number"
value={form.bountyPiasters || ''}
onChange={(e) =>
setForm({ ...form, bountyPiasters: parseInt(e.target.value) || 0 })
}
min={0}
step={100}
placeholder="0"
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{form.bountyPiasters > 0 && (
<p className="text-xs text-amber-500">
💰 {(form.bountyPiasters / 100).toLocaleString()} EGP
</p>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t bg-muted/30">
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-muted rounded text-[10px]">Enter</kbd> to create
quickly
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting || !form.title.trim()}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{isSubmitting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} />
)}
Create Card
</button>
</div>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { X, Keyboard } from 'lucide-react';
import { cn } from '@/lib/utils';
const SHORTCUTS = [
{ category: 'Navigation', shortcuts: [
{ keys: ['Ctrl', 'K'], description: 'Open command palette / search' },
{ keys: ['G', 'B'], description: 'Go to Boards' },
{ keys: ['G', 'T'], description: 'Go to My Tasks' },
{ keys: ['G', 'R'], description: 'Go to Reports' },
{ keys: ['G', 'M'], description: 'Go to Messages' },
{ keys: ['G', 'S'], description: 'Go to Salary' },
{ keys: ['G', 'P'], description: 'Go to Profile' },
{ keys: ['G', 'N'], description: 'Go to Notifications' },
]},
{ category: 'Board', shortcuts: [
{ keys: ['N'], description: 'New card (when on board)' },
{ keys: ['F'], description: 'Toggle filters' },
{ keys: ['1-6'], description: 'Switch to column view' },
{ keys: ['Esc'], description: 'Close card detail / dialog' },
]},
{ category: 'General', shortcuts: [
{ keys: ['?'], description: 'Show keyboard shortcuts' },
{ keys: ['Ctrl', '/'], description: 'Toggle dark mode' },
]},
];
export function KeyboardShortcutsHelp() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
setIsOpen((prev) => !prev);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-4" onClick={() => setIsOpen(false)}>
<div className="bg-card rounded-xl border shadow-2xl w-full max-w-lg max-h-[80vh] overflow-y-auto animate-fade-in" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
<div className="flex items-center gap-2">
<Keyboard size={18} />
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
</div>
<button onClick={() => setIsOpen(false)} className="p-1 rounded-md hover:bg-accent">
<X size={18} />
</button>
</div>
<div className="p-4 space-y-6">
{SHORTCUTS.map((group) => (
<div key={group.category}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{group.category}
</h3>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut, i) => (
<div key={i} className="flex items-center justify-between py-1">
<span className="text-sm text-muted-foreground">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j}>
{j > 0 && <span className="text-[10px] text-muted-foreground mx-0.5">+</span>}
<kbd className="px-2 py-0.5 bg-muted rounded text-xs font-mono font-medium border border-border/50">
{key}
</kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="p-4 border-t text-center">
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono">?</kbd> to toggle this panel
</p>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useOnlineStatus } from '@/hooks/use-online-status';
import { WifiOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[200] animate-slide-in-right">
<div className="flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-2.5 rounded-xl shadow-lg">
<WifiOff size={16} />
<span className="text-sm font-medium">
You're offline — changes will sync when reconnected
</span>
<Loader2 size={14} className="animate-spin ml-1" />
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
export function useNavigationShortcuts() {
const router = useRouter();
const pendingKeyRef = useRef<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
// Ctrl+K for search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
document.dispatchEvent(new CustomEvent('open-command-palette'));
return;
}
// Ctrl+/ for dark mode toggle
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
document.dispatchEvent(new CustomEvent('toggle-theme'));
return;
}
// Two-key navigation: G then letter
if (pendingKeyRef.current === 'g') {
pendingKeyRef.current = null;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
router.push('/boards');
break;
case 't':
e.preventDefault();
router.push('/my-tasks');
break;
case 'r':
e.preventDefault();
router.push('/reports');
break;
case 'm':
e.preventDefault();
router.push('/messages');
break;
case 's':
e.preventDefault();
router.push('/salary');
break;
case 'p':
e.preventDefault();
router.push('/profile');
break;
case 'n':
e.preventDefault();
router.push('/notifications');
break;
case 'd':
e.preventDefault();
router.push('/');
break;
}
return;
}
if (e.key === 'g' && !e.ctrlKey && !e.metaKey && !e.altKey) {
pendingKeyRef.current = 'g';
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
pendingKeyRef.current = null;
}, 500);
}
};
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('keydown', handler);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [router]);
}
\ No newline at end of file
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect } from 'react';
import { toast } from 'sonner';
interface QueuedAction { export function useOnlineStatus(): boolean {
id: string;
type: string;
url: string;
method: string;
body?: any;
timestamp: number;
}
const QUEUE_KEY = 'thegrind_offline_queue';
function getQueue(): QueuedAction[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveQueue(queue: QueuedAction[]): void {
if (typeof window === 'undefined') return;
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true); const [isOnline, setIsOnline] = useState(true);
const [queueLength, setQueueLength] = useState(0);
const isSyncing = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
setIsOnline(navigator.onLine); setIsOnline(navigator.onLine);
setQueueLength(getQueue().length);
const handleOnline = () => {
setIsOnline(true);
toast.success('Back online! Syncing queued actions...');
syncQueue();
};
const handleOffline = () => { const handleOnline = () => setIsOnline(true);
setIsOnline(false); const handleOffline = () => setIsOnline(false);
toast.warning('You are offline. Changes will be queued and synced when reconnected.');
};
window.addEventListener('online', handleOnline); window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline); window.addEventListener('offline', handleOffline);
...@@ -60,72 +22,5 @@ export function useOnlineStatus() { ...@@ -60,72 +22,5 @@ export function useOnlineStatus() {
}; };
}, []); }, []);
const enqueue = useCallback((action: Omit<QueuedAction, 'id' | 'timestamp'>) => { return isOnline;
const queue = getQueue();
const newAction: QueuedAction = {
...action,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
timestamp: Date.now(),
};
queue.push(newAction);
saveQueue(queue);
setQueueLength(queue.length);
return newAction.id;
}, []);
const syncQueue = useCallback(async () => {
if (isSyncing.current) return;
isSyncing.current = true;
const queue = getQueue();
if (queue.length === 0) {
isSyncing.current = false;
return;
}
const failed: QueuedAction[] = [];
let successCount = 0;
for (const action of queue) {
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(action.url, {
method: action.method,
headers,
body: action.body ? JSON.stringify(action.body) : undefined,
});
if (response.ok) {
successCount++;
} else if (response.status >= 500) {
failed.push(action);
}
// 4xx errors are dropped (client error, no point retrying)
} catch {
failed.push(action);
}
}
saveQueue(failed);
setQueueLength(failed.length);
if (successCount > 0) {
toast.success(`Synced ${successCount} queued action${successCount > 1 ? 's' : ''}`);
}
if (failed.length > 0) {
toast.error(`${failed.length} action${failed.length > 1 ? 's' : ''} failed to sync. Will retry later.`);
}
isSyncing.current = false;
}, []);
const clearQueue = useCallback(() => {
saveQueue([]);
setQueueLength(0);
}, []);
return { isOnline, queueLength, enqueue, syncQueue, clearQueue };
} }
\ 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