Commit 524d1e1e authored by Administrator's avatar Administrator

Update 19 files via Son of Anton

parent 35640bca
# ============================ # ============================================
# THE GRIND — Backend Environment Variables # THE GRIND — Backend Environment Variables
# ============================ # ============================================
# App # Application
NODE_ENV=development NODE_ENV=development
PORT=3001 PORT=3001
FRONTEND_URL=http://localhost:3000 FRONTEND_URL=http://localhost:3000
...@@ -15,16 +15,16 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=publi ...@@ -15,16 +15,16 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=publi
# Redis # Redis
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD= # REDIS_PASSWORD=
REDIS_DB=0 REDIS_DB=0
# JWT # JWT
JWT_SECRET=CHANGE_THIS_TO_A_REAL_SECRET_IN_PRODUCTION JWT_SECRET=CHANGE_ME_IN_PRODUCTION_OR_GET_HACKED_YOU_IDIOT
JWT_ACCESS_EXPIRY=15m JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d JWT_REFRESH_EXPIRY=7d
JWT_REFRESH_EXPIRY_DAYS=7 JWT_REFRESH_EXPIRY_DAYS=7
# MinIO # MinIO (S3-compatible storage)
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_USE_SSL=false MINIO_USE_SSL=false
...@@ -36,7 +36,8 @@ MINIO_BUCKET=hr-files ...@@ -36,7 +36,8 @@ MINIO_BUCKET=hr-files
MAX_FILE_SIZE_BYTES=26214400 MAX_FILE_SIZE_BYTES=26214400
MAX_PROFILE_PHOTO_SIZE_BYTES=5242880 MAX_PROFILE_PHOTO_SIZE_BYTES=5242880
# Rate Limiting # Session & Security
SESSION_TIMEOUT_HOURS=8
MAX_LOGIN_ATTEMPTS=5 MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30 LOCKOUT_DURATION_MINUTES=30
SESSION_TIMEOUT_HOURS=8 MAX_DAILY_LOGIN_ATTEMPTS=15
\ No newline at end of file \ No newline at end of file
# ============================================
# THE GRIND — Frontend Environment Variables
# ============================================
# Backend API URL (internal, for SSR)
NEXT_PUBLIC_API_URL=http://localhost:3001/api NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Backend WebSocket URL (for Socket.io client)
NEXT_PUBLIC_WS_URL=http://localhost:3001 NEXT_PUBLIC_WS_URL=http://localhost:3001
NEXT_PUBLIC_APP_NAME=The Grind
\ No newline at end of file # Public URL of this frontend (used for generating links)
NEXT_PUBLIC_APP_URL=http://localhost:3000
# MinIO public URL (for serving uploaded files/images)
NEXT_PUBLIC_MINIO_URL=http://localhost:9000
\ No newline at end of file
This diff is collapsed.
...@@ -84,4 +84,72 @@ ...@@ -84,4 +84,72 @@
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/30; @apply bg-muted-foreground/30;
}
/* HUD Animations */
@keyframes hud-pulse-red {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.3); }
}
@keyframes hud-pulse-gold {
0%, 100% { box-shadow: 0 0 0 0 rgba(234, 179, 8, 0); }
50% { box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.3); }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes count-up {
from { opacity: 0.5; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-hud-pulse-red {
animation: hud-pulse-red 3s ease-in-out;
}
.animate-hud-pulse-gold {
animation: hud-pulse-gold 3s ease-in-out;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
.animate-count {
animation: count-up 0.3s ease-out;
}
/* Safe area inset for mobile bottom bar */
.safe-area-inset-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Touch-friendly drag area */
@media (pointer: coarse) {
.kanban-card {
touch-action: none;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
} }
\ No newline at end of file
'use client';
import { formatEgp, cn } from '@/lib/utils';
interface DeductionCategory {
category: string;
label: string;
count: number;
totalPiasters: number;
color: string;
}
interface DeductionBreakdownChartProps {
data: DeductionCategory[];
className?: string;
}
const CATEGORY_COLORS: Record<string, string> = {
A: '#EF4444',
B: '#F97316',
C: '#EAB308',
D: '#8B5CF6',
};
const CATEGORY_LABELS: Record<string, string> = {
A: 'Deadline',
B: 'Reporting',
C: 'Quality',
D: 'Communication',
};
export function DeductionBreakdownChart({ data, className }: DeductionBreakdownChartProps) {
const total = data.reduce((sum, d) => sum + d.totalPiasters, 0);
if (data.length === 0 || total === 0) {
return (
<div className={cn('text-center py-8 text-muted-foreground text-sm', className)}>
No deductions this period 🎉
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Stacked bar */}
<div className="h-6 rounded-full overflow-hidden flex">
{data.map((cat) => {
const width = (cat.totalPiasters / total) * 100;
if (width === 0) return null;
return (
<div
key={cat.category}
className="h-full transition-all duration-500"
style={{ width: `${width}%`, backgroundColor: cat.color || CATEGORY_COLORS[cat.category] || '#6B7280' }}
title={`${cat.label || CATEGORY_LABELS[cat.category]}: ${formatEgp(cat.totalPiasters)} (${cat.count})`}
/>
);
})}
</div>
{/* Legend */}
<div className="space-y-2">
{data.map((cat) => {
const percentage = total > 0 ? Math.round((cat.totalPiasters / total) * 100) : 0;
return (
<div key={cat.category} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-sm shrink-0"
style={{ backgroundColor: cat.color || CATEGORY_COLORS[cat.category] || '#6B7280' }}
/>
<span className="text-muted-foreground">
{cat.label || CATEGORY_LABELS[cat.category] || cat.category}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{cat.count} deduction{cat.count !== 1 ? 's' : ''}</span>
<span className="font-mono font-medium text-red-500">{formatEgp(cat.totalPiasters)}</span>
<span className="text-xs text-muted-foreground w-8 text-right">{percentage}%</span>
</div>
</div>
);
})}
</div>
{/* Total */}
<div className="border-t pt-2 flex items-center justify-between font-medium text-sm">
<span>Total Deductions</span>
<span className="text-red-500 font-mono">{formatEgp(total)}</span>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { formatEgp, cn } from '@/lib/utils';
interface PayrollSummaryData {
month: string;
gross: number;
deductions: number;
bounties: number;
adjustments: number;
net: number;
contractorCount: number;
}
interface PayrollSummaryChartProps {
data: PayrollSummaryData[];
className?: string;
}
export function PayrollSummaryChart({ data, className }: PayrollSummaryChartProps) {
if (data.length === 0) {
return (
<div className={cn('text-center py-8 text-muted-foreground text-sm', className)}>
No payroll data available
</div>
);
}
const maxNet = Math.max(...data.map((d) => d.net));
return (
<div className={cn('space-y-4', className)}>
{/* Bar chart */}
<div className="flex items-end gap-2 h-32">
{data.map((point, i) => {
const height = maxNet > 0 ? (point.net / maxNet) * 100 : 0;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
<div
className="w-full bg-primary/20 hover:bg-primary/30 rounded-t transition-all cursor-pointer"
style={{ height: `${height}%`, minHeight: '4px' }}
/>
<span className="text-[9px] text-muted-foreground truncate w-full text-center">{point.month}</span>
{/* Tooltip */}
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block z-10">
<div className="bg-popover border rounded-lg shadow-lg p-2 text-[10px] whitespace-nowrap space-y-0.5">
<p className="font-medium">{point.month}</p>
<p>Gross: {formatEgp(point.gross)}</p>
<p className="text-emerald-500">Bounties: +{formatEgp(point.bounties)}</p>
<p className="text-red-500">Deductions: -{formatEgp(point.deductions)}</p>
<p className="text-blue-500">Adjustments: {formatEgp(point.adjustments)}</p>
<p className="font-bold border-t pt-0.5 mt-0.5">Net: {formatEgp(point.net)}</p>
<p className="text-muted-foreground">{point.contractorCount} contractors</p>
</div>
</div>
</div>
);
})}
</div>
{/* Summary table for latest month */}
{data.length > 0 && (
<div className="bg-muted/30 rounded-lg p-3 space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Latest: {data[data.length - 1].month}</span>
<span className="font-medium">{data[data.length - 1].contractorCount} contractors</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Net Payout</span>
<span className="font-bold">{formatEgp(data[data.length - 1].net)}</span>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useMemo } from 'react';
import { formatEgp } from '@/lib/utils';
interface DataPoint {
month: string;
salary: number;
deductions: number;
bounties: number;
net: number;
}
interface SalaryTrendChartProps {
data: DataPoint[];
className?: string;
}
export function SalaryTrendChart({ data, className }: SalaryTrendChartProps) {
const maxValue = useMemo(() => {
if (data.length === 0) return 100;
return Math.max(...data.map((d) => Math.max(d.salary, d.net))) * 1.1;
}, [data]);
if (data.length === 0) {
return (
<div className={`text-center py-8 text-muted-foreground text-sm ${className || ''}`}>
No salary data available
</div>
);
}
return (
<div className={className}>
<div className="flex items-end gap-1 h-40">
{data.map((point, i) => {
const salaryHeight = (point.salary / maxValue) * 100;
const netHeight = (point.net / maxValue) * 100;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
<div className="w-full flex items-end gap-px h-32">
<div
className="flex-1 bg-blue-500/20 rounded-t transition-all"
style={{ height: `${salaryHeight}%` }}
title={`Salary: ${formatEgp(point.salary)}`}
/>
<div
className="flex-1 bg-emerald-500/40 rounded-t transition-all"
style={{ height: `${netHeight}%` }}
title={`Net: ${formatEgp(point.net)}`}
/>
</div>
<span className="text-[9px] text-muted-foreground truncate w-full text-center">
{point.month}
</span>
{/* Tooltip on hover */}
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block z-10">
<div className="bg-popover border rounded-lg shadow-lg p-2 text-[10px] whitespace-nowrap space-y-0.5">
<p className="font-medium">{point.month}</p>
<p>Salary: {formatEgp(point.salary)}</p>
<p className="text-red-500">Deductions: -{formatEgp(point.deductions)}</p>
<p className="text-emerald-500">Bounties: +{formatEgp(point.bounties)}</p>
<p className="font-bold">Net: {formatEgp(point.net)}</p>
</div>
</div>
</div>
);
})}
</div>
<div className="flex items-center gap-4 mt-3 text-[10px] text-muted-foreground justify-center">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded bg-blue-500/40" /> Salary</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded bg-emerald-500/40" /> Net</span>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { cn } from '@/lib/utils';
interface TaskCompletionData {
label: string;
completed: number;
total: number;
color?: string;
}
interface TaskCompletionChartProps {
data: TaskCompletionData[];
className?: string;
}
export function TaskCompletionChart({ data, className }: TaskCompletionChartProps) {
if (data.length === 0) {
return (
<div className={cn('text-center py-8 text-muted-foreground text-sm', className)}>
No task data available
</div>
);
}
return (
<div className={cn('space-y-3', className)}>
{data.map((item, i) => {
const percentage = item.total > 0 ? Math.round((item.completed / item.total) * 100) : 0;
const barColor = item.color || (percentage >= 80 ? 'bg-emerald-500' : percentage >= 50 ? 'bg-yellow-500' : 'bg-red-500');
return (
<div key={i}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{item.label}</span>
<span className="text-xs text-muted-foreground">
{item.completed}/{item.total} ({percentage}%)
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', barColor)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
);
}
\ No newline at end of file
'use client';
import { UserAvatar } from '@/components/shared/user-avatar';
import { Shield, AlertTriangle, Skull } from 'lucide-react';
import { cn } from '@/lib/utils';
interface TeamMember {
id: string;
firstName: string;
lastName: string;
avatar?: string;
health: 'HEALTHY' | 'WARNING' | 'CRITICAL';
deductionCount: number;
retentionPercent: number;
currentStreak: number;
}
interface TeamHealthChartProps {
members: TeamMember[];
className?: string;
}
const healthConfig = {
HEALTHY: { icon: Shield, color: 'text-emerald-500', bg: 'bg-emerald-500/10', label: 'Healthy' },
WARNING: { icon: AlertTriangle, color: 'text-yellow-500', bg: 'bg-yellow-500/10', label: 'Warning' },
CRITICAL: { icon: Skull, color: 'text-red-500', bg: 'bg-red-500/10', label: 'Critical' },
};
export function TeamHealthChart({ members, className }: TeamHealthChartProps) {
if (members.length === 0) {
return (
<div className={cn('text-center py-8 text-muted-foreground text-sm', className)}>
No team members
</div>
);
}
const healthyCount = members.filter((m) => m.health === 'HEALTHY').length;
const warningCount = members.filter((m) => m.health === 'WARNING').length;
const criticalCount = members.filter((m) => m.health === 'CRITICAL').length;
return (
<div className={cn('space-y-4', className)}>
{/* Summary */}
<div className="flex gap-3">
<div className="flex items-center gap-1.5 text-xs">
<Shield size={12} className="text-emerald-500" />
<span>{healthyCount} Healthy</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
<AlertTriangle size={12} className="text-yellow-500" />
<span>{warningCount} Warning</span>
</div>
<div className="flex items-center gap-1.5 text-xs">
<Skull size={12} className="text-red-500" />
<span>{criticalCount} Critical</span>
</div>
</div>
{/* Member list */}
<div className="space-y-2">
{members.map((member) => {
const config = healthConfig[member.health];
const Icon = config.icon;
return (
<div key={member.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent/50 transition-colors">
<UserAvatar firstName={member.firstName} lastName={member.lastName} avatar={member.avatar} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{member.firstName} {member.lastName}</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>🔥 {member.currentStreak}d streak</span>
<span>·</span>
<span>{member.deductionCount} deductions</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', member.retentionPercent >= 80 ? 'bg-emerald-500' : member.retentionPercent >= 60 ? 'bg-yellow-500' : 'bg-red-500')}
style={{ width: `${Math.min(100, member.retentionPercent)}%` }}
/>
</div>
<span className={cn('text-xs', config.color)}>{member.retentionPercent}%</span>
<Icon size={14} className={config.color} />
</div>
</div>
);
})}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useCallback } from 'react';
import { Upload, X, FileIcon, Image as ImageIcon, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface FileUploadProps {
onUpload: (file: File) => Promise<void>;
accept?: string;
maxSizeMB?: number;
multiple?: boolean;
className?: string;
label?: string;
disabled?: boolean;
}
export function FileUpload({
onUpload,
accept,
maxSizeMB = 25,
multiple = false,
className,
label = 'Drop files here or click to upload',
disabled = false,
}: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
const validateFile = (file: File): boolean => {
if (file.size > maxSizeMB * 1024 * 1024) {
toast.error(`File "${file.name}" exceeds ${maxSizeMB}MB limit`);
return false;
}
if (accept) {
const acceptedTypes = accept.split(',').map((t) => t.trim());
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
if (type.endsWith('/*')) {
return file.type.startsWith(type.replace('/*', '/'));
}
return file.type === type;
});
if (!isAccepted) {
toast.error(`File type "${file.type}" is not accepted`);
return false;
}
}
return true;
};
const handleFiles = useCallback(
async (files: FileList | File[]) => {
const validFiles = Array.from(files).filter(validateFile);
if (validFiles.length === 0) return;
setIsUploading(true);
setUploadQueue(validFiles);
for (const file of validFiles) {
try {
await onUpload(file);
} catch (err: any) {
toast.error(`Failed to upload "${file.name}": ${err.message || 'Unknown error'}`);
}
}
setIsUploading(false);
setUploadQueue([]);
},
[onUpload, accept, maxSizeMB],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled || isUploading) return;
handleFiles(e.dataTransfer.files);
},
[handleFiles, disabled, isUploading],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
handleFiles(e.target.files);
e.target.value = '';
}
};
return (
<div
className={cn(
'relative border-2 border-dashed rounded-xl p-6 text-center transition-colors cursor-pointer',
isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50',
disabled && 'opacity-50 cursor-not-allowed',
isUploading && 'pointer-events-none',
className,
)}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => {
if (!disabled && !isUploading) {
document.getElementById('file-upload-input')?.click();
}
}}
>
<input
id="file-upload-input"
type="file"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
className="hidden"
disabled={disabled || isUploading}
/>
{isUploading ? (
<div className="space-y-2">
<Loader2 size={32} className="mx-auto animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
Uploading {uploadQueue.length} file{uploadQueue.length > 1 ? 's' : ''}...
</p>
</div>
) : (
<div className="space-y-2">
<Upload size={32} className="mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-xs text-muted-foreground">Max {maxSizeMB}MB per file</p>
</div>
)}
</div>
);
}
interface FilePreviewProps {
name: string;
size: number;
url?: string;
mimeType?: string;
onRemove?: () => void;
}
export function FilePreview({ name, size, url, mimeType, onRemove }: FilePreviewProps) {
const isImage = mimeType?.startsWith('image/');
const sizeStr = size > 1048576 ? `${(size / 1048576).toFixed(1)} MB` : `${Math.round(size / 1024)} KB`;
return (
<div className="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 group">
{isImage && url ? (
<img src={url} alt={name} className="w-10 h-10 rounded object-cover" />
) : (
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
<FileIcon size={18} className="text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{name}</p>
<p className="text-xs text-muted-foreground">{sizeStr}</p>
</div>
{onRemove && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { apiGet } from '@/lib/api';
import { Check, X, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Label {
id: string;
name: string;
color: string;
}
interface LabelSelectorProps {
value: string[];
onChange: (ids: string[]) => void;
boardId?: string;
className?: string;
}
export function LabelSelector({ value, onChange, boardId, className }: LabelSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [labels, setLabels] = useState<Label[]>([]);
useEffect(() => {
loadLabels();
}, [boardId]);
const loadLabels = async () => {
try {
const endpoints = [apiGet('/labels', { limit: 100 })];
if (boardId) endpoints.push(apiGet(`/boards/${boardId}/labels`, { limit: 100 }));
const results = await Promise.all(endpoints);
const allLabels = results.flatMap((r) => r.data || []);
const unique = allLabels.filter(
(label, index, arr) => arr.findIndex((l) => l.id === label.id) === index,
);
setLabels(unique);
} catch {
/* fail silently */
}
};
const toggleLabel = (labelId: string) => {
if (value.includes(labelId)) {
onChange(value.filter((id) => id !== labelId));
} else {
onChange([...value, labelId]);
}
};
const selectedLabels = labels.filter((l) => value.includes(l.id));
return (
<div className={cn('relative', className)}>
{/* Selected labels */}
<div className="flex flex-wrap gap-1 mb-1">
{selectedLabels.map((label) => (
<span
key={label.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ backgroundColor: `${label.color}20`, color: label.color }}
>
{label.name}
<button onClick={() => toggleLabel(label.id)}>
<X size={10} />
</button>
</span>
))}
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs text-muted-foreground hover:bg-accent border border-dashed"
>
<Plus size={10} /> Label
</button>
</div>
{/* Dropdown */}
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
<div className="absolute z-20 mt-1 w-full bg-card rounded-lg border shadow-lg max-h-48 overflow-y-auto p-1">
{labels.length === 0 ? (
<p className="p-2 text-xs text-muted-foreground text-center">No labels available</p>
) : (
labels.map((label) => {
const isSelected = value.includes(label.id);
return (
<button
key={label.id}
onClick={() => toggleLabel(label.id)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-accent transition-colors',
isSelected && 'bg-accent/50',
)}
>
<span
className="w-3 h-3 rounded-sm shrink-0"
style={{ backgroundColor: label.color }}
/>
<span className="flex-1">{label.name}</span>
{isSelected && <Check size={12} className="text-primary" />}
</button>
);
})
)}
</div>
</>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { cn } from '@/lib/utils';
const PRIORITIES = [
{ value: 'CRITICAL', label: 'Critical', color: 'bg-red-500', textColor: 'text-red-500', emoji: '🔴' },
{ value: 'HIGH', label: 'High', color: 'bg-orange-500', textColor: 'text-orange-500', emoji: '🟠' },
{ value: 'MEDIUM', label: 'Medium', color: 'bg-yellow-500', textColor: 'text-yellow-500', emoji: '🟡' },
{ value: 'LOW', label: 'Low', color: 'bg-green-500', textColor: 'text-green-500', emoji: '🟢' },
{ value: 'NONE', label: 'None', color: 'bg-gray-300', textColor: 'text-muted-foreground', emoji: '⚪' },
] as const;
interface PrioritySelectorProps {
value: string;
onChange: (value: string) => void;
variant?: 'dropdown' | 'buttons';
className?: string;
}
export function PrioritySelector({ value, onChange, variant = 'dropdown', className }: PrioritySelectorProps) {
if (variant === 'buttons') {
return (
<div className={cn('flex gap-1', className)}>
{PRIORITIES.map((p) => (
<button
key={p.value}
onClick={() => onChange(p.value)}
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-md text-xs border transition-colors',
value === p.value
? `${p.textColor} bg-current/10 border-current/20 font-medium`
: 'text-muted-foreground hover:bg-accent',
)}
>
<span>{p.emoji}</span>
<span>{p.label}</span>
</button>
))}
</div>
);
}
const selected = PRIORITIES.find((p) => p.value === value) || PRIORITIES[4];
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn('px-3 py-2 rounded-lg border bg-background text-sm', className)}
>
{PRIORITIES.map((p) => (
<option key={p.value} value={p.value}>
{p.emoji} {p.label}
</option>
))}
</select>
);
}
export function PriorityBadge({ priority }: { priority: string }) {
const p = PRIORITIES.find((pr) => pr.value === priority);
if (!p || p.value === 'NONE') return null;
return (
<span className={cn('inline-flex items-center gap-0.5 text-[10px] font-medium', p.textColor)}>
<span className={cn('w-1.5 h-1.5 rounded-full', p.color)} />
{p.label}
</span>
);
}
\ No newline at end of file
'use client';
import { Building, Home, X as XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'];
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday'];
const DAY_OPTIONS = [
{ value: 'IN_OFFICE', label: 'Office', icon: Building, emoji: '🏢', color: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
{ value: 'REMOTE', label: 'Remote', icon: Home, emoji: '🏠', color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
{ value: 'OFF', label: 'Off', icon: XIcon, emoji: '❌', color: 'bg-muted text-muted-foreground border-border' },
];
interface SchedulePickerProps {
value: Record<string, string>;
onChange: (schedule: Record<string, string>) => void;
showSalaryPreview?: boolean;
contractorType?: 'FULL_TIME' | 'INTERN';
className?: string;
disabled?: boolean;
}
export function SchedulePicker({
value,
onChange,
showSalaryPreview = false,
contractorType = 'FULL_TIME',
className,
disabled = false,
}: SchedulePickerProps) {
const setDay = (day: string, type: string) => {
if (disabled) return;
onChange({ ...value, [day]: type });
};
const calculateBaseSalary = (): number => {
const isFullTime = contractorType === 'FULL_TIME';
let total = 0;
for (const [, type] of Object.entries(value)) {
if (type === 'IN_OFFICE') total += isFullTime ? 240000 : 100000;
else if (type === 'REMOTE') total += isFullTime ? 160000 : 50000;
}
return total;
};
const workingDays = Object.values(value).filter((v) => v !== 'OFF').length;
const inOfficeDays = Object.values(value).filter((v) => v === 'IN_OFFICE').length;
const remoteDays = Object.values(value).filter((v) => v === 'REMOTE').length;
const baseSalary = calculateBaseSalary();
return (
<div className={cn('space-y-3', className)}>
{DAY_NAMES.map((day, i) => (
<div key={day} className="flex items-center justify-between p-3 rounded-lg border">
<span className="text-sm font-medium w-28">{DAY_LABELS[i]}</span>
<div className="flex gap-1.5">
{DAY_OPTIONS.map((opt) => {
const isSelected = value[day] === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setDay(day, opt.value)}
disabled={disabled}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors',
isSelected ? `${opt.color} ring-1 ring-primary/30` : 'hover:bg-accent/50',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
<span>{opt.emoji}</span>
<span>{opt.label}</span>
</button>
);
})}
</div>
</div>
))}
{showSalaryPreview && (
<div className="bg-accent rounded-xl p-4 space-y-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Base Monthly Salary</p>
<p className="text-3xl font-bold">
EGP {(baseSalary / 100).toLocaleString()}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{workingDays} working days/week</span>
<span>·</span>
<span>{inOfficeDays} in-office</span>
<span>·</span>
<span>{remoteDays} remote</span>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect, useCallback } from 'react';
import { apiGet } from '@/lib/api';
import { UserAvatar } from '@/components/shared/user-avatar';
import { Search, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useDebounce } from '@/hooks/use-debounce';
interface UserSelectorProps {
value: string[];
onChange: (ids: string[]) => void;
boardId?: string;
roleFilter?: string;
placeholder?: string;
maxSelections?: number;
className?: string;
}
export function UserSelector({
value,
onChange,
boardId,
roleFilter,
placeholder = 'Search users...',
maxSelections,
className,
}: UserSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [selectedUsers, setSelectedUsers] = useState<any[]>([]);
const debouncedSearch = useDebounce(search, 300);
useEffect(() => {
loadUsers();
}, [debouncedSearch, boardId, roleFilter]);
useEffect(() => {
if (value.length > 0 && selectedUsers.length === 0) {
loadSelectedUsers();
}
}, [value]);
const loadUsers = async () => {
try {
const params: Record<string, any> = { limit: 20, status: 'ACTIVE' };
if (debouncedSearch) params.search = debouncedSearch;
if (roleFilter) params.role = roleFilter;
const res = await apiGet('/users', params);
setUsers(res.data || []);
} catch {
/* fail silently */
}
};
const loadSelectedUsers = async () => {
try {
const res = await apiGet('/users', { limit: 100, status: 'ACTIVE' });
const allUsers = res.data || [];
setSelectedUsers(allUsers.filter((u: any) => value.includes(u.id)));
} catch {
/* fail silently */
}
};
const toggleUser = useCallback(
(user: any) => {
const isSelected = value.includes(user.id);
if (isSelected) {
const next = value.filter((id) => id !== user.id);
onChange(next);
setSelectedUsers((prev) => prev.filter((u) => u.id !== user.id));
} else {
if (maxSelections && value.length >= maxSelections) return;
onChange([...value, user.id]);
setSelectedUsers((prev) => [...prev, user]);
}
},
[value, onChange, maxSelections],
);
const removeUser = (userId: string) => {
onChange(value.filter((id) => id !== userId));
setSelectedUsers((prev) => prev.filter((u) => u.id !== userId));
};
return (
<div className={cn('relative', className)}>
{/* Selected chips */}
{selectedUsers.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-accent text-xs"
>
<UserAvatar firstName={user.firstName} lastName={user.lastName} avatar={user.avatar} size="xs" />
<span>{user.firstName} {user.lastName}</span>
<button onClick={() => removeUser(user.id)} className="hover:text-destructive">
<X size={12} />
</button>
</span>
))}
</div>
)}
{/* Search input */}
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Dropdown */}
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
<div className="absolute z-20 mt-1 w-full bg-card rounded-lg border shadow-lg max-h-48 overflow-y-auto">
{users.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground text-center">No users found</p>
) : (
users.map((user) => {
const isSelected = value.includes(user.id);
return (
<button
key={user.id}
onClick={() => toggleUser(user)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-accent transition-colors text-sm',
isSelected && 'bg-accent/50',
)}
>
<UserAvatar firstName={user.firstName} lastName={user.lastName} avatar={user.avatar} size="xs" />
<div className="flex-1 min-w-0">
<span className="truncate">{user.firstName} {user.lastName}</span>
<span className="text-xs text-muted-foreground ml-1">@{user.username}</span>
</div>
{isSelected && <Check size={14} className="text-primary shrink-0" />}
</button>
);
})
)}
</div>
</>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/stores/auth.store';
import { useNotificationStore } from '@/stores/notification.store';
import { useIsMobile } from '@/hooks/use-media-query';
import { cn } from '@/lib/utils';
import {
Menu, X, LayoutDashboard, Kanban, ListTodo, FileText,
Wallet, MessageSquare, Bell, Star, Clock, Calendar,
Users, Settings, Shield, BarChart3, UserCog, Send,
AlertTriangle, DollarSign, BookOpen, GraduationCap,
} from 'lucide-react';
interface MobileNavItem {
label: string;
href: string;
icon: React.ElementType;
roles?: string[];
badge?: number;
}
const NAV_ITEMS: MobileNavItem[] = [
{ 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 },
{ label: 'Messages', href: '/messages', icon: MessageSquare },
{ label: 'Notifications', href: '/notifications', icon: Bell },
{ label: 'Evaluations', href: '/evaluations', icon: Star },
{ label: 'Learning', href: '/learning', icon: GraduationCap },
{ label: 'Schedule', href: '/schedule', icon: Clock },
{ label: 'Meetings', href: '/meetings', icon: Calendar },
{ label: 'Directory', href: '/directory', icon: Users },
];
const ADMIN_ITEMS: MobileNavItem[] = [
{ label: 'Contractors', href: '/admin/contractors', icon: UserCog, 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: 'Analytics', href: '/admin/analytics', icon: BarChart3, roles: ['SUPER_ADMIN', 'ADMIN'] },
{ label: 'Settings', href: '/admin/settings', icon: Settings, roles: ['SUPER_ADMIN'] },
];
export function MobileNav() {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const { unreadCount } = useNotificationStore();
const isMobile = useIsMobile();
if (!isMobile) return null;
const userRole = user?.role || 'CONTRACTOR';
const visibleAdminItems = ADMIN_ITEMS.filter(
(item) => !item.roles || item.roles.includes(userRole),
);
return (
<>
{/* Hamburger button */}
<button
onClick={() => setIsOpen(true)}
className="fixed top-3 left-3 z-50 p-2 rounded-lg bg-card border shadow-sm md:hidden"
aria-label="Open navigation"
>
<Menu size={20} />
</button>
{/* Overlay */}
{isOpen && (
<div
className="fixed inset-0 z-[60] bg-black/50 md:hidden"
onClick={() => setIsOpen(false)}
/>
)}
{/* Slide-out panel */}
<div
className={cn(
'fixed top-0 left-0 z-[70] h-full w-72 bg-card border-r shadow-xl transform transition-transform duration-300 md:hidden',
isOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
{/* Header */}
<div className="h-14 flex items-center justify-between px-4 border-b">
<span className="font-black text-lg tracking-tighter">THE GRIND</span>
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-md hover:bg-accent"
>
<X size={18} />
</button>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto py-3 px-2">
<div className="space-y-0.5">
{NAV_ITEMS.map((item) => {
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
const Icon = item.icon;
const showBadge = item.href === '/notifications' && unreadCount > 0;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors relative',
isActive
? 'bg-accent font-medium'
: 'hover:bg-accent/50 text-muted-foreground',
)}
>
<Icon size={18} />
<span>{item.label}</span>
{showBadge && (
<span className="absolute right-3 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>
)}
</Link>
);
})}
</div>
{visibleAdminItems.length > 0 && (
<>
<div className="my-3 px-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/40">
Admin
</p>
</div>
<div className="space-y-0.5">
{visibleAdminItems.map((item) => {
const isActive = pathname.startsWith(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setIsOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors',
isActive
? 'bg-accent font-medium'
: 'hover:bg-accent/50 text-muted-foreground',
)}
>
<Icon size={18} />
<span>{item.label}</span>
</Link>
);
})}
</div>
</>
)}
</nav>
{/* User section */}
{user && (
<div className="p-3 border-t">
<Link
href="/profile"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent transition-colors"
>
<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>
<p className="text-sm font-medium">{user.firstName} {user.lastName}</p>
<p className="text-[10px] text-muted-foreground uppercase">{user.role?.replace('_', ' ')}</p>
</div>
</Link>
</div>
)}
</div>
{/* Bottom tab bar for quick access */}
<div className="fixed bottom-0 left-0 right-0 z-40 bg-card border-t flex items-center justify-around py-1.5 px-2 md:hidden safe-area-inset-bottom">
{[
{ href: '/', icon: LayoutDashboard, label: 'Home' },
{ href: '/boards', icon: Kanban, label: 'Boards' },
{ href: '/reports/submit', icon: FileText, label: 'Report' },
{ href: '/messages', icon: MessageSquare, label: 'Messages' },
{ href: '/notifications', icon: Bell, label: 'Alerts', badge: unreadCount },
].map((tab) => {
const isActive = pathname === tab.href || (tab.href !== '/' && pathname.startsWith(tab.href));
const Icon = tab.icon;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
'flex flex-col items-center gap-0.5 px-3 py-1 rounded-lg transition-colors relative',
isActive ? 'text-primary' : 'text-muted-foreground',
)}
>
<Icon size={20} />
<span className="text-[9px]">{tab.label}</span>
{tab.badge && tab.badge > 0 && (
<span className="absolute -top-0.5 right-1 min-w-[14px] h-[14px] bg-destructive text-destructive-foreground rounded-full text-[8px] font-bold flex items-center justify-center px-0.5">
{tab.badge > 99 ? '99+' : tab.badge}
</span>
)}
</Link>
);
})}
</div>
</>
);
}
\ No newline at end of file
'use client';
import { Component, type ReactNode } from 'react';
import { AlertTriangle, RotateCcw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="min-h-[300px] flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mb-4">
<AlertTriangle size={28} className="text-destructive" />
</div>
<h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
<p className="text-sm text-muted-foreground mb-1 max-w-md">
An unexpected error occurred while rendering this section.
</p>
{this.state.error && (
<pre className="text-xs text-muted-foreground bg-muted rounded-lg p-3 mt-2 max-w-md overflow-auto">
{this.state.error.message}
</pre>
)}
<button
onClick={this.handleReset}
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<RotateCcw size={14} />
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 768px)');
}
export function useIsTablet(): boolean {
return useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
}
export function useIsDesktop(): boolean {
return useMediaQuery('(min-width: 1025px)');
}
export function usePrefersDarkMode(): boolean {
return useMediaQuery('(prefers-color-scheme: dark)');
}
export function usePrefersReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}
\ No newline at end of file
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
interface QueuedAction {
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 [queueLength, setQueueLength] = useState(0);
const isSyncing = useRef(false);
useEffect(() => {
if (typeof window === 'undefined') return;
setIsOnline(navigator.onLine);
setQueueLength(getQueue().length);
const handleOnline = () => {
setIsOnline(true);
toast.success('Back online! Syncing queued actions...');
syncQueue();
};
const handleOffline = () => {
setIsOnline(false);
toast.warning('You are offline. Changes will be queued and synced when reconnected.');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const enqueue = useCallback((action: Omit<QueuedAction, 'id' | 'timestamp'>) => {
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
This diff is collapsed.
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