Commit 524d1e1e authored by Administrator's avatar Administrator

Update 19 files via Son of Anton

parent 35640bca
# ============================
# ============================================
# THE GRIND — Backend Environment Variables
# ============================
# ============================================
# App
# Application
NODE_ENV=development
PORT=3001
FRONTEND_URL=http://localhost:3000
......@@ -15,16 +15,16 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/thegrind?schema=publi
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# REDIS_PASSWORD=
REDIS_DB=0
# 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_REFRESH_EXPIRY=7d
JWT_REFRESH_EXPIRY_DAYS=7
# MinIO
# MinIO (S3-compatible storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
......@@ -36,7 +36,8 @@ MINIO_BUCKET=hr-files
MAX_FILE_SIZE_BYTES=26214400
MAX_PROFILE_PHOTO_SIZE_BYTES=5242880
# Rate Limiting
# Session & Security
SESSION_TIMEOUT_HOURS=8
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
SESSION_TIMEOUT_HOURS=8
\ No newline at end of file
MAX_DAILY_LOGIN_ATTEMPTS=15
\ 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
# Backend WebSocket URL (for Socket.io client)
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
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { StatusBadge } from '@/components/shared/status-badge';
import { formatDate } from '@/lib/date';
import { cn } from '@/lib/utils';
import { Layers, Kanban, FileText, AlertTriangle, Plus, Edit2, Trash2, Copy, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
type TemplateTab = 'board' | 'card' | 'deduction-preset';
export default function TemplatesPage() {
const [activeTab, setActiveTab] = useState<TemplateTab>('board');
const [boardTemplates, setBoardTemplates] = useState<any[]>([]);
const [cardTemplates, setCardTemplates] = useState<any[]>([]);
const [deductionPresets, setDeductionPresets] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<{ type: TemplateTab; item: any } | null>(null);
// Board template form
const [showBoardForm, setShowBoardForm] = useState(false);
const [boardForm, setBoardForm] = useState({ name: '', description: '' });
const [isSavingBoard, setIsSavingBoard] = useState(false);
// Card template form
const [showCardForm, setShowCardForm] = useState(false);
const [cardForm, setCardForm] = useState({
name: '', title: '', description: '', priority: 'NONE', estimatedHours: 0, boardId: '',
});
const [isSavingCard, setIsSavingCard] = useState(false);
const [boards, setBoards] = useState<any[]>([]);
// Deduction preset form
const [showPresetForm, setShowPresetForm] = useState(false);
const [presetForm, setPresetForm] = useState({
name: '', category: 'A', subCategory: 'A1', description: '', defaultAmountPiasters: 0,
});
const [isSavingPreset, setIsSavingPreset] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [boardRes, cardRes, boardListRes] = await Promise.all([
apiGet('/boards', { limit: 100, isTemplate: true }).catch(() => ({ data: [] })),
apiGet('/cards/templates', { limit: 100 }).catch(() => ({ data: [] })),
apiGet('/boards', { limit: 100 }),
]);
setBoardTemplates(boardRes.data || []);
setCardTemplates(cardRes.data || []);
setBoards(boardListRes.data || []);
// Try loading deduction presets
try {
const presetRes = await apiGet('/deductions/presets', { limit: 100 });
setDeductionPresets(presetRes.data || []);
} catch {
setDeductionPresets([]);
}
} catch (err) {
console.error('Failed to load templates:', err);
} finally {
setIsLoading(false);
}
};
const handleCreateBoardTemplate = async () => {
if (!boardForm.name) { toast.error('Name is required'); return; }
setIsSavingBoard(true);
try {
await apiPost('/boards/templates', boardForm);
toast.success('Board template created');
setShowBoardForm(false);
setBoardForm({ name: '', description: '' });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to create template');
} finally {
setIsSavingBoard(false);
}
};
const handleCreateCardTemplate = async () => {
if (!cardForm.name || !cardForm.title) { toast.error('Name and title are required'); return; }
setIsSavingCard(true);
try {
await apiPost('/cards/templates', cardForm);
toast.success('Card template created');
setShowCardForm(false);
setCardForm({ name: '', title: '', description: '', priority: 'NONE', estimatedHours: 0, boardId: '' });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to create template');
} finally {
setIsSavingCard(false);
}
};
const handleCreatePreset = async () => {
if (!presetForm.name) { toast.error('Name is required'); return; }
setIsSavingPreset(true);
try {
await apiPost('/deductions/presets', presetForm);
toast.success('Deduction preset created');
setShowPresetForm(false);
setPresetForm({ name: '', category: 'A', subCategory: 'A1', description: '', defaultAmountPiasters: 0 });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to create preset');
} finally {
setIsSavingPreset(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
const endpoints: Record<TemplateTab, string> = {
board: `/boards/templates/${deleteTarget.item.id}`,
card: `/cards/templates/${deleteTarget.item.id}`,
'deduction-preset': `/deductions/presets/${deleteTarget.item.id}`,
};
await apiDelete(endpoints[deleteTarget.type]);
toast.success('Deleted successfully');
setDeleteTarget(null);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Templates & Presets"
description="Manage reusable board templates, card templates, and deduction presets"
/>
{/* Tabs */}
<div className="flex gap-1 border-b pb-0">
{[
{ key: 'board' as const, label: 'Board Templates', icon: Kanban, count: boardTemplates.length },
{ key: 'card' as const, label: 'Card Templates', icon: FileText, count: cardTemplates.length },
{ key: 'deduction-preset' as const, label: 'Deduction Presets', icon: AlertTriangle, count: deductionPresets.length },
].map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={cn(
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px',
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<Icon size={16} />
{tab.label}
<span className="text-xs bg-muted px-1.5 py-0.5 rounded-full">{tab.count}</span>
</button>
);
})}
</div>
{/* Board Templates Tab */}
{activeTab === 'board' && (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={() => setShowBoardForm(!showBoardForm)} className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90">
<Plus size={16} /> New Board Template
</button>
</div>
{showBoardForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">New Board Template</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Template Name *</label>
<input type="text" value={boardForm.name} onChange={(e) => setBoardForm({ ...boardForm, name: e.target.value })} placeholder="e.g., Game Project Board" 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-1">
<label className="text-sm font-medium">Description</label>
<input type="text" value={boardForm.description} onChange={(e) => setBoardForm({ ...boardForm, description: e.target.value })} placeholder="What is this template for?" 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>
<div className="flex justify-end gap-2">
<button onClick={() => setShowBoardForm(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCreateBoardTemplate} disabled={isSavingBoard} className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSavingBoard ? <Loader2 size={14} className="animate-spin" /> : <Layers size={14} />}
Create
</button>
</div>
</div>
)}
{boardTemplates.length === 0 ? (
<EmptyState icon={Kanban} title="No board templates" description="Create a board template to quickly set up new projects." />
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{boardTemplates.map((tpl) => (
<div key={tpl.id} className="bg-card rounded-xl border p-4 hover:border-primary/30 transition-all">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="text-sm font-semibold">{tpl.name}</h4>
{tpl.description && <p className="text-xs text-muted-foreground mt-0.5">{tpl.description}</p>}
</div>
<div className="flex gap-1">
<button onClick={() => setDeleteTarget({ type: 'board', item: tpl })} className="p-1.5 text-muted-foreground hover:text-destructive rounded"><Trash2 size={14} /></button>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{tpl.columnCount && <span>{tpl.columnCount} columns</span>}
{tpl.createdAt && <span>Created {formatDate(tpl.createdAt)}</span>}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Card Templates Tab */}
{activeTab === 'card' && (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={() => setShowCardForm(!showCardForm)} className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90">
<Plus size={16} /> New Card Template
</button>
</div>
{showCardForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">New Card Template</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Template Name *</label>
<input type="text" value={cardForm.name} onChange={(e) => setCardForm({ ...cardForm, name: e.target.value })} placeholder="e.g., Bug Report Template" 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-1">
<label className="text-sm font-medium">Card Title *</label>
<input type="text" value={cardForm.title} onChange={(e) => setCardForm({ ...cardForm, title: e.target.value })} placeholder="e.g., [BUG] " 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-1">
<label className="text-sm font-medium">Priority</label>
<select value={cardForm.priority} onChange={(e) => setCardForm({ ...cardForm, 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 className="space-y-1">
<label className="text-sm font-medium">Board Scope (optional)</label>
<select value={cardForm.boardId} onChange={(e) => setCardForm({ ...cardForm, boardId: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="">Organization-wide</option>
{boards.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Description Template</label>
<textarea value={cardForm.description} onChange={(e) => setCardForm({ ...cardForm, description: e.target.value })} rows={4} placeholder="Pre-filled description content..." 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>
<div className="flex justify-end gap-2">
<button onClick={() => setShowCardForm(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCreateCardTemplate} disabled={isSavingCard} className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSavingCard ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
Create
</button>
</div>
</div>
)}
{cardTemplates.length === 0 ? (
<EmptyState icon={FileText} title="No card templates" description="Create reusable card templates for recurring task types." />
) : (
<div className="bg-card rounded-xl border divide-y">
{cardTemplates.map((tpl) => (
<div key={tpl.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{tpl.name}</h4>
{tpl.priority && tpl.priority !== 'NONE' && <StatusBadge status={tpl.priority} />}
{tpl.boardId ? (
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded">Board-level</span>
) : (
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded">Org-wide</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{tpl.title}</p>
</div>
<button onClick={() => setDeleteTarget({ type: 'card', item: tpl })} className="p-2 text-muted-foreground hover:text-destructive"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
</div>
)}
{/* Deduction Presets Tab */}
{activeTab === 'deduction-preset' && (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={() => setShowPresetForm(!showPresetForm)} className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90">
<Plus size={16} /> New Preset
</button>
</div>
{showPresetForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">New Deduction Preset</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Preset Name *</label>
<input type="text" value={presetForm.name} onChange={(e) => setPresetForm({ ...presetForm, name: e.target.value })} placeholder="e.g., Late Report — 3rd Occurrence" 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-1">
<label className="text-sm font-medium">Category</label>
<select value={presetForm.category} onChange={(e) => setPresetForm({ ...presetForm, category: e.target.value, subCategory: e.target.value + '1' })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="A">A — Deadline</option>
<option value="B">B — Reporting</option>
<option value="C">C — Quality</option>
<option value="D">D — Communication</option>
</select>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Default Description</label>
<textarea value={presetForm.description} onChange={(e) => setPresetForm({ ...presetForm, description: e.target.value })} rows={3} placeholder="Pre-filled violation description..." 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>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPresetForm(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCreatePreset} disabled={isSavingPreset} className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSavingPreset ? <Loader2 size={14} className="animate-spin" /> : <AlertTriangle size={14} />}
Create
</button>
</div>
</div>
)}
{deductionPresets.length === 0 ? (
<EmptyState icon={AlertTriangle} title="No deduction presets" description="Create presets to speed up common deduction types." />
) : (
<div className="bg-card rounded-xl border divide-y">
{deductionPresets.map((preset) => (
<div key={preset.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{preset.name}</h4>
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">{preset.category}{preset.subCategory}</span>
</div>
{preset.description && <p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{preset.description}</p>}
</div>
<button onClick={() => setDeleteTarget({ type: 'deduction-preset', item: preset })} className="p-2 text-muted-foreground hover:text-destructive"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title={`Delete ${deleteTarget?.type === 'board' ? 'Board Template' : deleteTarget?.type === 'card' ? 'Card Template' : 'Deduction Preset'}`}
description="This action cannot be undone. Are you sure?"
confirmLabel="Delete"
destructive
/>
</div>
);
}
\ No newline at end of file
......@@ -84,4 +84,72 @@
::-webkit-scrollbar-thumb:hover {
@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
import { z } from 'zod';
// ==========================================
// AUTH VALIDATORS
// ==========================================
export const loginSchema = z.object({
login: z.string().min(1, 'Username or email is required'),
password: z.string().min(1, 'Password is required'),
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z
.string()
.min(10, 'Password must be at least 10 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter')
.regex(/[a-z]/, 'Must contain at least one lowercase letter')
.regex(/[0-9]/, 'Must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Must contain at least one special character'),
confirmPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
// ==========================================
// USER VALIDATORS
// ==========================================
export const registerSchema = z.object({
firstName: z.string().min(2, 'Min 2 characters').max(50),
lastName: z.string().min(2, 'Min 2 characters').max(50),
nameArabic: z.string().min(4, 'Min 4 characters').max(100),
nationalId: z.string().regex(/^\d{14}$/, 'Must be 14 digits'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
phone: z.string().regex(/^01[0-9]{9}$/, 'Invalid Egyptian phone number'),
phoneSecondary: z.string().regex(/^01[0-9]{9}$/, 'Invalid phone number').optional().or(z.literal('')),
address: z.string().min(20, 'Min 20 characters'),
emergencyContactName: z.string().min(4, 'Min 4 characters'),
emergencyContactPhone: z.string().regex(/^01[0-9]{9}$/, 'Invalid phone number'),
emergencyContactRelationship: z.enum(['Parent', 'Sibling', 'Spouse', 'Friend', 'Other']),
bankName: z.string().min(1, 'Bank name is required'),
bankAccountNumber: z.string().min(1, 'Account number is required'),
bankAccountHolderName: z.string().min(4, 'Min 4 characters'),
username: z.string().min(3, 'Min 3 characters').max(30).regex(/^[a-zA-Z0-9_]+$/, 'Only alphanumeric and underscore'),
password: z
.string()
.min(10, 'Min 10 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export const updateProfileSchema = z.object({
phone: z.string().regex(/^01[0-9]{9}$/, 'Invalid phone number').optional(),
phoneSecondary: z.string().regex(/^01[0-9]{9}$/, 'Invalid phone number').optional().or(z.literal('')),
address: z.string().min(20, 'Min 20 characters').optional(),
emergencyContactName: z.string().min(4, 'Min 4 characters').optional(),
emergencyContactPhone: z.string().regex(/^01[0-9]{9}$/, 'Invalid phone number').optional(),
emergencyContactRelationship: z.string().optional(),
bankName: z.string().min(1).optional(),
bankAccountNumber: z.string().min(1).optional(),
bankAccountHolderName: z.string().min(4).optional(),
});
// ==========================================
// BOARD VALIDATORS
// ==========================================
export const createBoardSchema = z.object({
name: z.string().min(1, 'Board name is required').max(100),
description: z.string().max(500).optional(),
key: z.string().min(1, 'Board key is required').max(10).regex(/^[A-Z0-9_]+$/, 'Uppercase letters, numbers, underscore only'),
visibility: z.enum(['PRIVATE', 'PUBLIC']).default('PRIVATE'),
allowContractorCreation: z.boolean().default(true),
autoArchiveDoneCardsDays: z.number().min(7).max(365).default(30),
});
// ==========================================
// CARD VALIDATORS
// ==========================================
export const createCardSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().optional(),
priority: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'NONE']).default('NONE'),
dueDate: z.string().optional(),
estimatedHours: z.number().min(0).optional(),
bountyPiasters: z.number().min(0).optional(),
columnId: z.string().uuid().optional(),
boardId: z.string().uuid(),
});
export const moveCardSchema = z.object({
columnId: z.string().uuid('Invalid column'),
position: z.number().min(0),
frozenReason: z.string().min(20, 'Frozen reason must be at least 20 characters').optional(),
});
// ==========================================
// REPORT VALIDATORS
// ==========================================
export const taskEntrySchema = z.object({
cardId: z.string().optional(),
description: z.string().min(50, 'Description must be at least 50 characters'),
timeSpentMinutes: z.number().min(15, 'Minimum 15 minutes'),
status: z.enum(['IN_PROGRESS', 'COMPLETED', 'BLOCKED']),
});
export const submitReportSchema = z.object({
reportDate: z.string().min(1, 'Report date is required'),
taskEntries: z.array(taskEntrySchema).min(1, 'At least one task entry is required'),
blockers: z.string().optional(),
additionalNotes: z.string().optional(),
mood: z.enum(['FRUSTRATED', 'NEUTRAL', 'GOOD', 'ON_FIRE']).optional(),
});
// ==========================================
// DEDUCTION VALIDATORS
// ==========================================
export const createDeductionSchema = z.object({
userId: z.string().uuid('Select a contractor'),
category: z.enum(['A', 'B', 'C', 'D']),
subCategory: z.string().min(2),
cardId: z.string().uuid().optional(),
violationDate: z.string().min(1, 'Violation date is required'),
description: z.string().min(100, 'Description must be at least 100 characters'),
amountPiasters: z.number().min(0).optional(),
});
export const respondDeductionSchema = z.object({
response: z.enum(['ACCEPT', 'DISPUTE']),
responseText: z.string().optional(),
}).refine(
(data) => data.response !== 'DISPUTE' || (data.responseText && data.responseText.length >= 100),
{ message: 'Dispute explanation must be at least 100 characters', path: ['responseText'] },
);
// ==========================================
// ADJUSTMENT VALIDATORS
// ==========================================
export const createAdjustmentSchema = z.object({
userId: z.string().uuid('Select a contractor'),
type: z.enum(['POSITIVE', 'NEGATIVE']),
category: z.enum(['ADVANCE', 'REIMBURSEMENT', 'BONUS', 'CORRECTION', 'LOAN', 'OTHER']),
amountPiasters: z.number().min(1, 'Amount must be positive'),
description: z.string().min(50, 'Description must be at least 50 characters'),
effectiveMonth: z.number().min(1).max(12),
effectiveYear: z.number().min(2020),
});
// ==========================================
// PIP VALIDATORS
// ==========================================
export const createPipSchema = z.object({
userId: z.string().uuid('Select a contractor'),
durationDays: z.number().min(30).max(60),
specificIssues: z.string().min(50, 'Specific issues must be at least 50 characters'),
improvementTargets: z.string().min(50, 'Improvement targets must be at least 50 characters'),
successCriteria: z.string().min(100, 'Success criteria must be at least 100 characters'),
consequenceOfFailure: z.string().default('Termination of engagement.'),
checkInSchedule: z.enum(['WEEKLY', 'BIWEEKLY']).default('WEEKLY'),
});
// ==========================================
// EVALUATION VALIDATORS
// ==========================================
export const technicalEvalSchema = z.object({
codeQuality: z.number().min(1).max(5),
codeQualityNotes: z.string().min(20, 'Justification required (min 20 chars)'),
taskCompletion: z.number().min(1).max(5),
deadlineCompliance: z.number().min(1).max(5),
technicalGrowth: z.number().min(1).max(5),
technicalGrowthNotes: z.string().min(20, 'Justification required (min 20 chars)'),
problemSolving: z.number().min(1).max(5),
problemSolvingNotes: z.string().min(20, 'Justification required (min 20 chars)'),
});
export const professionalEvalSchema = z.object({
reportingCompliance: z.number().min(1).max(5),
communicationQuality: z.number().min(1).max(5),
communicationNotes: z.string().min(20, 'Justification required (min 20 chars)'),
collaboration: z.number().min(1).max(5),
collaborationNotes: z.string().min(20, 'Justification required (min 20 chars)'),
reliability: z.number().min(1).max(5),
reliabilityNotes: z.string().min(20, 'Justification required (min 20 chars)'),
policyCompliance: z.number().min(1).max(5),
});
// ==========================================
// MEETING VALIDATORS
// ==========================================
export const createMeetingSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().optional(),
startTime: z.string().min(1, 'Start time is required'),
endTime: z.string().min(1, 'End time is required'),
inviteeIds: z.array(z.string().uuid()).min(1, 'At least one invitee is required'),
location: z.string().optional(),
recurrence: z.enum(['NONE', 'WEEKLY', 'BIWEEKLY', 'MONTHLY']).default('NONE'),
});
// ==========================================
// SCHEDULE CHANGE VALIDATORS
// ==========================================
export const scheduleChangeSchema = z.object({
proposedSchedule: z.record(z.string(), z.enum(['IN_OFFICE', 'REMOTE', 'OFF'])),
effectiveDate: z.string().min(1, 'Effective date is required'),
reason: z.string().min(50, 'Reason must be at least 50 characters'),
});
// ==========================================
// NOTICE VALIDATORS
// ==========================================
export const createNoticeSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(1, 'Content is required'),
type: z.enum(['GENERAL_ANNOUNCEMENT', 'OFFICIAL_WARNING', 'POLICY_UPDATE', 'CUSTOM']),
isBlocking: z.boolean().default(false),
targetRoles: z.array(z.string()).optional(),
});
// ==========================================
// WEBHOOK VALIDATORS
// ==========================================
export const createWebhookSchema = z.object({
url: z.string().url('Must be a valid URL'),
secret: z.string().optional(),
events: z.array(z.string()).min(1, 'Select at least one event'),
isActive: z.boolean().default(true),
});
// Type exports for forms
export type LoginFormData = z.infer<typeof loginSchema>;
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
export type RegisterFormData = z.infer<typeof registerSchema>;
export type CreateBoardFormData = z.infer<typeof createBoardSchema>;
export type CreateCardFormData = z.infer<typeof createCardSchema>;
export type SubmitReportFormData = z.infer<typeof submitReportSchema>;
export type CreateDeductionFormData = z.infer<typeof createDeductionSchema>;
export type CreateAdjustmentFormData = z.infer<typeof createAdjustmentSchema>;
export type CreatePipFormData = z.infer<typeof createPipSchema>;
export type CreateMeetingFormData = z.infer<typeof createMeetingSchema>;
export type ScheduleChangeFormData = z.infer<typeof scheduleChangeSchema>;
\ 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