Commit 731cd6e6 authored by Administrator's avatar Administrator

Update 10 files via Son of Anton

parent 78d69125
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet, apiPost } from '@/lib/api';
import { Loader2, ChevronLeft, ChevronRight, Check, Upload, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
const STEPS = [
'Validate Invite',
'Account Creation',
'Schedule Configuration',
'Contract Signing',
'Competency Self-Assessment',
'Complete',
];
const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'];
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday'];
const COMPETENCY_AREAS = [
'Device maintenance, debugging, and OS troubleshooting',
'Collaborative work and source control (Git)',
'C# mastery: data structures, algorithms, OOP',
'Design patterns, architecture, parallel/concurrent programming',
'Legacy code: maintenance, debugging, upgrading',
'Unity Game Development and render pipelines',
'Deployment: PC, Android, Web',
'Unity Netcode for GameObjects + Unity multiplayer services',
'Industry-standard Unity assets (DOTween, Feel, TextMeshPro, etc.)',
'Unity + MySQL: basic CRUD',
'Unity + Firebase services',
];
const COMPETENCY_LEVELS = [
{ value: 0, label: 'No Knowledge', desc: 'I have never worked with this' },
{ value: 1, label: 'Beginner', desc: 'Basic awareness but no practical experience' },
{ value: 2, label: 'Developing', desc: 'Some hands-on experience but need guidance' },
{ value: 3, label: 'Competent', desc: 'Can work independently on standard tasks' },
{ value: 4, label: 'Advanced', desc: 'Can handle complex tasks and mentor others' },
{ value: 5, label: 'Expert', desc: 'Subject matter expert' },
];
export default function RegisterPage() {
const { inviteCode } = useParams<{ inviteCode: string }>();
const router = useRouter();
const [currentStep, setCurrentStep] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [invite, setInvite] = useState<any>(null);
const [error, setError] = useState('');
// Step 2: Account data
const [showPassword, setShowPassword] = useState(false);
const [account, setAccount] = useState({
firstName: '', lastName: '', nameArabic: '',
nationalId: '', dateOfBirth: '',
phone: '', phoneSecondary: '', address: '',
emergencyContactName: '', emergencyContactPhone: '', emergencyContactRelationship: 'Parent',
bankName: '', bankAccountNumber: '', bankAccountHolderName: '',
username: '', password: '', confirmPassword: '',
});
// Step 3: Schedule
const [schedule, setSchedule] = useState<Record<string, string>>({
sunday: 'OFF', monday: 'OFF', tuesday: 'OFF', wednesday: 'OFF', thursday: 'OFF',
});
// Step 4: Contract
const [contractScrolled, setContractScrolled] = useState(false);
const [contractChecks, setContractChecks] = useState<Record<string, boolean>>({
deductionPolicy: false, ipAssignment: false, nda: false,
terminationTerms: false, codeOfConduct: false, dataPolicy: false, salaryAdjustment: false,
});
const [signatureName, setSignatureName] = useState('');
const [signatureConfirm, setSignatureConfirm] = useState(false);
// Step 5: Competency
const [competencies, setCompetencies] = useState<Record<number, number>>({});
// Validate invite on mount
useEffect(() => {
validateInvite();
}, [inviteCode]);
const validateInvite = async () => {
try {
const res = await apiGet(`/onboarding/invite/${inviteCode}`);
setInvite(res.data);
setCurrentStep(1);
} catch (err: any) {
setError(err.message || 'Invalid or expired invite code.');
} finally {
setIsLoading(false);
}
};
// Step 2 submit
const handleAccountSubmit = async () => {
if (account.password !== account.confirmPassword) {
toast.error('Passwords do not match'); return;
}
if (account.password.length < 10) {
toast.error('Password must be at least 10 characters'); return;
}
if (!account.firstName || !account.lastName || !account.username) {
toast.error('Please fill all required fields'); return;
}
setCurrentStep(2);
};
// Step 3 submit
const handleScheduleSubmit = () => {
const workingDays = Object.values(schedule).filter(v => v !== 'OFF').length;
if (workingDays < 1) {
toast.error('Select at least 1 working day'); return;
}
setCurrentStep(3);
};
// Step 4 submit
const handleContractSubmit = () => {
if (!contractScrolled) { toast.error('Please read the entire contract'); return; }
const allChecked = Object.values(contractChecks).every(v => v);
if (!allChecked) { toast.error('Please acknowledge all clauses'); return; }
if (signatureName.toLowerCase() !== `${account.firstName} ${account.lastName}`.toLowerCase()) {
toast.error('Signature must match your full name'); return;
}
if (!signatureConfirm) { toast.error('Please confirm your digital signature'); return; }
setCurrentStep(4);
};
// Step 5 submit — final registration
const handleFinalSubmit = async () => {
if (Object.keys(competencies).length < COMPETENCY_AREAS.length) {
toast.error('Please rate all competency areas'); return;
}
setIsSubmitting(true);
try {
await apiPost('/onboarding/register', {
inviteCode,
firstName: account.firstName,
lastName: account.lastName,
nameArabic: account.nameArabic,
nationalId: account.nationalId,
dateOfBirth: account.dateOfBirth,
phone: account.phone,
phoneSecondary: account.phoneSecondary || undefined,
address: account.address,
emergencyContactName: account.emergencyContactName,
emergencyContactPhone: account.emergencyContactPhone,
emergencyContactRelationship: account.emergencyContactRelationship,
bankName: account.bankName,
bankAccountNumber: account.bankAccountNumber,
bankAccountHolderName: account.bankAccountHolderName,
username: account.username,
password: account.password,
weeklySchedule: schedule,
competencySelfAssessment: competencies,
contractSigned: true,
signatureName,
signatureTimestamp: new Date().toISOString(),
});
toast.success('Registration complete! Please log in.');
setCurrentStep(5);
setTimeout(() => router.push('/login'), 3000);
} catch (err: any) {
toast.error(err.message || 'Registration failed');
} finally {
setIsSubmitting(false);
}
};
// Salary calculation
const calculateBaseSalary = () => {
if (!invite) return 0;
const isFullTime = invite.contractorType === 'FULL_TIME';
let total = 0;
for (const [, type] of Object.entries(schedule)) {
if (type === 'IN_OFFICE') total += isFullTime ? 240000 : 100000;
else if (type === 'REMOTE') total += isFullTime ? 160000 : 50000;
}
return total;
};
const formatEgp = (piasters: number) => `EGP ${(piasters / 100).toLocaleString()}`;
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="animate-spin" size={32} />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="bg-card rounded-xl border p-8 max-w-md w-full text-center space-y-4">
<div className="text-4xl">🚫</div>
<h1 className="text-xl font-bold">Invalid Invitation</h1>
<p className="text-sm text-muted-foreground">{error}</p>
<button onClick={() => router.push('/login')} className="text-sm text-primary hover:underline">
Go to Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted">
{/* Progress Bar */}
<div className="sticky top-0 z-50 bg-background/90 backdrop-blur-sm border-b px-4 py-3">
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold">Step {currentStep + 1} of {STEPS.length}</span>
<span className="text-xs text-muted-foreground">{STEPS[currentStep]}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${((currentStep + 1) / STEPS.length) * 100}%` }}
/>
</div>
</div>
</div>
<div className="max-w-3xl mx-auto p-6">
{/* STEP 1: Account Creation */}
{currentStep === 1 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Welcome to The Grind</h1>
<p className="text-muted-foreground">
You've been invited as a <strong>{invite?.contractorType === 'FULL_TIME' ? 'Full-Timer' : 'Intern'}</strong>.
{invite?.welcomeNote && <span className="block mt-1 italic">"{invite.welcomeNote}"</span>}
</p>
</div>
<div className="bg-card rounded-xl border p-6 space-y-4">
<h2 className="font-semibold">Personal Information</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="First Name (English) *" value={account.firstName} onChange={v => setAccount({ ...account, firstName: v })} placeholder="John" />
<Field label="Last Name (English) *" value={account.lastName} onChange={v => setAccount({ ...account, lastName: v })} placeholder="Doe" />
<Field label="Full Name (Arabic) *" value={account.nameArabic} onChange={v => setAccount({ ...account, nameArabic: v })} placeholder="الاسم بالعربي" className="sm:col-span-2" />
<Field label="National ID *" value={account.nationalId} onChange={v => setAccount({ ...account, nationalId: v })} placeholder="14 digits" />
<Field label="Date of Birth *" value={account.dateOfBirth} onChange={v => setAccount({ ...account, dateOfBirth: v })} type="date" />
<Field label="Phone (Primary) *" value={account.phone} onChange={v => setAccount({ ...account, phone: v })} placeholder="01XXXXXXXXX" />
<Field label="Phone (Secondary)" value={account.phoneSecondary} onChange={v => setAccount({ ...account, phoneSecondary: v })} placeholder="01XXXXXXXXX" />
<Field label="Address *" value={account.address} onChange={v => setAccount({ ...account, address: v })} placeholder="Full residential address" className="sm:col-span-2" />
</div>
<h2 className="font-semibold pt-4">Emergency Contact</h2>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Name *" value={account.emergencyContactName} onChange={v => setAccount({ ...account, emergencyContactName: v })} />
<Field label="Phone *" value={account.emergencyContactPhone} onChange={v => setAccount({ ...account, emergencyContactPhone: v })} placeholder="01XXXXXXXXX" />
<div className="space-y-1">
<label className="text-sm font-medium">Relationship *</label>
<select value={account.emergencyContactRelationship} onChange={e => setAccount({ ...account, emergencyContactRelationship: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
{['Parent', 'Sibling', 'Spouse', 'Friend', 'Other'].map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
<h2 className="font-semibold pt-4">Bank Details</h2>
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Bank Name *" value={account.bankName} onChange={v => setAccount({ ...account, bankName: v })} placeholder="e.g. CIB" />
<Field label="Account Number *" value={account.bankAccountNumber} onChange={v => setAccount({ ...account, bankAccountNumber: v })} />
<Field label="Holder Name *" value={account.bankAccountHolderName} onChange={v => setAccount({ ...account, bankAccountHolderName: v })} />
</div>
<h2 className="font-semibold pt-4">Login Credentials</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Username *" value={account.username} onChange={v => setAccount({ ...account, username: v })} placeholder="3-30 chars, alphanumeric" className="sm:col-span-2" />
<div className="space-y-1">
<label className="text-sm font-medium">Password *</label>
<div className="relative">
<input type={showPassword ? 'text' : 'password'} value={account.password} onChange={e => setAccount({ ...account, password: e.target.value })} placeholder="Min 10 characters" className="w-full px-3 py-2 pr-10 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" tabIndex={-1}>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<Field label="Confirm Password *" value={account.confirmPassword} onChange={v => setAccount({ ...account, confirmPassword: v })} type="password" />
</div>
</div>
<div className="flex justify-end">
<button onClick={handleAccountSubmit} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90">
Next <ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* STEP 2: Schedule Configuration */}
{currentStep === 2 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Configure Your Schedule</h1>
<p className="text-muted-foreground">Select your working days. This determines your base salary.</p>
</div>
<div className="bg-card rounded-xl border p-6 space-y-4">
<div className="text-sm font-medium text-muted-foreground mb-2">
Contractor Type: <strong className="text-foreground">{invite?.contractorType === 'FULL_TIME' ? 'Full-Timer' : 'Intern'}</strong>
</div>
<div className="space-y-3">
{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-2">
{[
{ value: 'IN_OFFICE', label: '🏢 In Office', color: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
{ value: 'REMOTE', label: '🏠 Remote', color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
{ value: 'OFF', label: '❌ Off', color: 'bg-muted text-muted-foreground' },
].map(opt => (
<button
key={opt.value}
onClick={() => setSchedule(prev => ({ ...prev, [day]: opt.value }))}
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${schedule[day] === opt.value ? opt.color + ' font-bold ring-1 ring-primary/30' : 'hover:bg-accent/50'}`}
>
{opt.label}
</button>
))}
</div>
</div>
))}
</div>
<div className="bg-accent rounded-xl p-4 mt-4">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">Your Base Monthly Salary</p>
<p className="text-3xl font-bold">{formatEgp(calculateBaseSalary())}</p>
<p className="text-xs text-muted-foreground mt-1">
{Object.entries(schedule).filter(([, v]) => v !== 'OFF').length} working days/week
</p>
</div>
</div>
<div className="flex justify-between">
<button onClick={() => setCurrentStep(1)} className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent">
<ChevronLeft size={16} /> Back
</button>
<button onClick={handleScheduleSubmit} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90">
Next <ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* STEP 3: Contract Signing */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Contract & Policy Acknowledgment</h1>
<p className="text-muted-foreground">Read and sign your service agreement.</p>
</div>
<div className="bg-card rounded-xl border">
<div
className="p-6 max-h-96 overflow-y-auto text-sm text-muted-foreground leading-relaxed space-y-4"
onScroll={(e) => {
const el = e.currentTarget;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 50) {
setContractScrolled(true);
}
}}
>
<h3 className="text-lg font-bold text-foreground">AL-ARCADE COMPREHENSIVE SERVICE AGREEMENT</h3>
<p>This agreement is between AL-Arcade ("the Company") and <strong>{account.firstName} {account.lastName}</strong> ("the Contractor").</p>
<p><strong>Contractor Type:</strong> {invite?.contractorType === 'FULL_TIME' ? 'Full-Timer' : 'Intern'}</p>
<p><strong>Base Salary:</strong> {formatEgp(calculateBaseSalary())}</p>
<p><strong>Effective Date:</strong> {new Date().toLocaleDateString()}</p>
<h4 className="font-semibold text-foreground mt-6">1. Deduction Policy</h4>
<p>The Contractor acknowledges that deductions may be applied for deadline violations, reporting violations, quality violations, and communication violations as outlined in the company's deduction matrix. Deductions are categorized from A1 to D4 with amounts proportional to the violation severity and the Contractor's daily rate or monthly salary.</p>
<h4 className="font-semibold text-foreground mt-4">2. Intellectual Property Assignment</h4>
<p>All work product, code, designs, assets, and intellectual property created during the engagement are the exclusive property of AL-Arcade. The Contractor assigns all rights, title, and interest in and to any work product to the Company.</p>
<h4 className="font-semibold text-foreground mt-4">3. Non-Disclosure Agreement</h4>
<p>The Contractor agrees to maintain strict confidentiality regarding all proprietary information, trade secrets, business strategies, client data, and internal operations of AL-Arcade for the duration of the engagement and for a period of 2 years following termination.</p>
<h4 className="font-semibold text-foreground mt-4">4. Termination Terms</h4>
<p>Either party may terminate this agreement with 30 calendar days written notice. The Company may terminate immediately for cause including but not limited to: PIP failure, reaching the 40% deduction threshold, falsified reports, or disappearance (3+ consecutive unreported working days).</p>
<h4 className="font-semibold text-foreground mt-4">5. Code of Conduct</h4>
<p>The Contractor agrees to maintain professional conduct in all system interactions, complete daily reports on time, respond to communications within 24 working hours, and attend all scheduled meetings.</p>
<h4 className="font-semibold text-foreground mt-4">6. Data & Security Policy</h4>
<p>The Contractor agrees to follow all data security protocols, use strong passwords, not share credentials, and report any security concerns immediately.</p>
<h4 className="font-semibold text-foreground mt-4">7. Salary Adjustment</h4>
<p>The Contractor acknowledges that their actual salary may be adjusted by the Super Admin at any time. The base salary calculated from the schedule is a starting point and may be overridden higher or lower based on the Contractor's experience, performance, and market factors.</p>
<div className="h-20" /> {/* Extra space to ensure scrollable */}
</div>
{!contractScrolled && (
<p className="text-xs text-center text-yellow-600 py-2 bg-yellow-50 dark:bg-yellow-900/10">
⚠️ Scroll to the bottom to continue
</p>
)}
</div>
{contractScrolled && (
<div className="bg-card rounded-xl border p-6 space-y-3">
<h3 className="font-semibold">Acknowledgments</h3>
{[
['deductionPolicy', 'I acknowledge and accept the Deduction Policy'],
['ipAssignment', 'I acknowledge and accept the Intellectual Property Assignment'],
['nda', 'I acknowledge and accept the Non-Disclosure Agreement'],
['terminationTerms', 'I acknowledge and accept the Termination Terms'],
['codeOfConduct', 'I acknowledge and accept the Code of Conduct'],
['dataPolicy', 'I acknowledge and accept the Data & Security Policy'],
['salaryAdjustment', 'I acknowledge that my salary may be adjusted by the Super Admin'],
].map(([key, label]) => (
<label key={key} className="flex items-start gap-3 cursor-pointer p-2 rounded hover:bg-accent/50">
<input type="checkbox" checked={contractChecks[key]} onChange={e => setContractChecks({ ...contractChecks, [key]: e.target.checked })} className="rounded mt-0.5" />
<span className="text-sm">{label}</span>
</label>
))}
<div className="border-t pt-4 mt-4 space-y-3">
<h3 className="font-semibold">Digital Signature</h3>
<div className="space-y-1">
<label className="text-sm font-medium">Type your full legal name exactly: "{account.firstName} {account.lastName}"</label>
<input type="text" value={signatureName} onChange={e => setSignatureName(e.target.value)} placeholder={`${account.firstName} ${account.lastName}`} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={signatureConfirm} onChange={e => setSignatureConfirm(e.target.checked)} className="rounded mt-0.5" />
<span className="text-sm">I confirm this constitutes my legally binding digital signature</span>
</label>
</div>
</div>
)}
<div className="flex justify-between">
<button onClick={() => setCurrentStep(2)} className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent">
<ChevronLeft size={16} /> Back
</button>
<button onClick={handleContractSubmit} disabled={!contractScrolled} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50">
Next <ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* STEP 4: Competency Self-Assessment */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Competency Self-Assessment</h1>
<p className="text-muted-foreground">Rate your skill level in each area. Be honest — gaps rated 0-1 will generate learning goals.</p>
</div>
<div className="space-y-4">
{COMPETENCY_AREAS.map((area, index) => (
<div key={index} className="bg-card rounded-xl border p-4">
<h3 className="text-sm font-semibold mb-3">{index + 1}. {area}</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
{COMPETENCY_LEVELS.map(level => (
<button
key={level.value}
onClick={() => setCompetencies(prev => ({ ...prev, [index]: level.value }))}
className={`p-2 rounded-lg border text-center transition-colors ${
competencies[index] === level.value
? level.value <= 1 ? 'bg-red-500/10 border-red-500/30 text-red-600'
: level.value <= 3 ? 'bg-blue-500/10 border-blue-500/30 text-blue-600'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-600'
: 'hover:bg-accent/50'
}`}
>
<div className="text-lg font-bold">{level.value}</div>
<div className="text-[10px] leading-tight">{level.label}</div>
</button>
))}
</div>
</div>
))}
</div>
<div className="bg-accent rounded-xl p-4">
<p className="text-sm">
<strong>{Object.keys(competencies).length}</strong> of {COMPETENCY_AREAS.length} areas rated.
{Object.values(competencies).filter(v => v <= 1).length > 0 && (
<span className="text-yellow-600 ml-2">
⚠️ {Object.values(competencies).filter(v => v <= 1).length} learning gap(s) detected — goals will be auto-created.
</span>
)}
</p>
</div>
<div className="flex justify-between">
<button onClick={() => setCurrentStep(3)} className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent">
<ChevronLeft size={16} /> Back
</button>
<button onClick={handleFinalSubmit} disabled={isSubmitting || Object.keys(competencies).length < COMPETENCY_AREAS.length} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <><Loader2 size={16} className="animate-spin" /> Registering...</> : <><Check size={16} /> Complete Registration</>}
</button>
</div>
</div>
)}
{/* STEP 5: Complete */}
{currentStep === 5 && (
<div className="text-center py-20 space-y-4">
<div className="text-6xl">🎉</div>
<h1 className="text-3xl font-bold">Welcome to The Grind!</h1>
<p className="text-muted-foreground">Your account has been created. Redirecting to login...</p>
<Loader2 className="animate-spin mx-auto" />
</div>
)}
</div>
</div>
);
}
function Field({ label, value, onChange, type = 'text', placeholder, className }: {
label: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string; className?: string;
}) {
return (
<div className={`space-y-1 ${className || ''}`}>
<label className="text-sm font-medium">{label}</label>
<input type={type} value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet, apiPut, apiPost } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDate, formatDateTime } from '@/lib/date';
import { formatEgp } from '@/lib/utils';
import {
ArrowLeft, User, Phone, Building, Shield, Calendar, DollarSign, Edit2,
Save, X, Loader2, Key, AlertTriangle, LogOut, Wallet, Star, TrendingDown,
TrendingUp, Flame, Clock, FileText,
} from 'lucide-react';
import { toast } from 'sonner';
export default function ContractorDetailPage() {
const { contractorId } = useParams<{ contractorId: string }>();
const router = useRouter();
const currentUser = useAuthStore((s) => s.user);
const [contractor, setContractor] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editData, setEditData] = useState<any>({});
const [showSalaryModal, setShowSalaryModal] = useState(false);
const [newSalary, setNewSalary] = useState('');
const [salaryReason, setSalaryReason] = useState('');
useEffect(() => { loadContractor(); }, [contractorId]);
const loadContractor = async () => {
try {
const res = await apiGet(`/users/${contractorId}`);
setContractor(res.data);
} catch (err: any) {
toast.error(err.message || 'Failed to load contractor');
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
await apiPut(`/users/${contractorId}`, editData);
toast.success('Contractor updated');
setIsEditing(false);
loadContractor();
} catch (err: any) {
toast.error(err.message || 'Failed to update');
} finally {
setIsSaving(false);
}
};
const handleSetSalary = async () => {
if (!newSalary) { toast.error('Enter a salary amount'); return; }
try {
await apiPut(`/users/${contractorId}`, {
actualSalaryPiasters: Math.round(parseFloat(newSalary) * 100),
});
toast.success('Salary updated');
setShowSalaryModal(false);
setNewSalary('');
setSalaryReason('');
loadContractor();
} catch (err: any) {
toast.error(err.message || 'Failed to set salary');
}
};
const handleResetPassword = async () => {
if (!confirm('Generate a temporary password for this contractor?')) return;
try {
const res = await apiPost(`/users/${contractorId}/reset-password`, {});
const tempPassword = res.data?.tempPassword || res.data;
toast.success(`Temporary password: ${tempPassword}`, { duration: 30000 });
} catch (err: any) {
toast.error(err.message || 'Failed to reset password');
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!contractor) return <p className="p-6 text-muted-foreground">Contractor not found.</p>;
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
const c = contractor;
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/admin/contractors')} className="p-2 rounded-md hover:bg-accent">
<ArrowLeft size={16} />
</button>
<PageHeader
title={`${c.firstName} ${c.lastName}`}
description={`@${c.username} · ${c.contractorType?.replace('_', ' ') || 'Contractor'}`}
actions={
<div className="flex gap-2">
{isSuperAdmin && (
<>
<button onClick={handleResetPassword} className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border hover:bg-accent">
<Key size={14} /> Reset Password
</button>
<button onClick={() => setShowSalaryModal(true)} className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90">
<DollarSign size={14} /> Set Salary
</button>
</>
)}
</div>
}
/>
</div>
{/* Header Card */}
<div className="bg-card rounded-xl border p-6 flex items-center gap-6">
<UserAvatar firstName={c.firstName} lastName={c.lastName} avatar={c.avatar} size="lg" />
<div className="flex-1">
<h2 className="text-xl font-bold">{c.firstName} {c.lastName}</h2>
{c.nameArabic && <p className="text-sm text-muted-foreground">{c.nameArabic}</p>}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={c.status || 'ACTIVE'} />
<StatusBadge status={c.contractorType || 'CONTRACTOR'} />
<StatusBadge status={c.role || 'CONTRACTOR'} />
</div>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Actual Salary</p>
<p className="text-2xl font-bold">{c.actualSalaryPiasters ? formatEgp(c.actualSalaryPiasters) : '—'}</p>
<p className="text-xs text-muted-foreground">Base: {c.baseSalaryPiasters ? formatEgp(c.baseSalaryPiasters) : '—'}</p>
</div>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard icon={Flame} label="Streak" value={`${c.currentStreak || 0} days`} sub={`Best: ${c.bestStreak || 0}`} />
<StatCard icon={TrendingDown} label="Deductions (Month)" value={c.deductionCountThisMonth || 0} color="text-red-500" />
<StatCard icon={TrendingUp} label="Bounties (Month)" value={c.bountyCountThisMonth || 0} color="text-emerald-500" />
<StatCard icon={Star} label="Last Eval" value={c.lastEvaluationScore?.toFixed(1) || '—'} />
</div>
{/* Personal Info */}
<Section title="Personal Information" icon={User}>
<InfoGrid items={[
['Full Name (English)', `${c.firstName} ${c.lastName}`],
['Full Name (Arabic)', c.nameArabic || '—'],
['National ID', c.nationalId ? '••••••••••' + c.nationalId.slice(-4) : '—'],
['Date of Birth', c.dateOfBirth ? formatDate(c.dateOfBirth) : '—'],
['Username', `@${c.username}`],
]} />
</Section>
{/* Contact Info */}
<Section title="Contact Information" icon={Phone}>
<InfoGrid items={[
['Phone (Primary)', c.phone || '—'],
['Phone (Secondary)', c.phoneSecondary || '—'],
['Address', c.address || '—'],
]} />
</Section>
{/* Emergency Contact */}
<Section title="Emergency Contact" icon={Shield}>
<InfoGrid items={[
['Name', c.emergencyContactName || '—'],
['Phone', c.emergencyContactPhone || '—'],
['Relationship', c.emergencyContactRelationship || '—'],
]} />
</Section>
{/* Employment */}
<Section title="Employment" icon={Calendar}>
<InfoGrid items={[
['Contractor Type', c.contractorType?.replace('_', ' ') || '—'],
['Status', c.status || '—'],
['Role', c.role?.replace('_', ' ') || '—'],
['Registered', c.createdAt ? formatDate(c.createdAt) : '—'],
['Activated', c.activatedAt ? formatDate(c.activatedAt) : '—'],
['Base Salary', c.baseSalaryPiasters ? formatEgp(c.baseSalaryPiasters) : '—'],
['Actual Salary', c.actualSalaryPiasters ? formatEgp(c.actualSalaryPiasters) : '—'],
]} />
</Section>
{/* Bank Details */}
<Section title="Bank Details" icon={Building}>
<InfoGrid items={[
['Bank Name', c.bankName || '—'],
['Account Number', c.bankAccountNumber ? '••••' + c.bankAccountNumber.slice(-4) : '—'],
['Account Holder', c.bankAccountHolderName || '—'],
]} />
</Section>
{/* Schedule */}
{c.weeklySchedule && (
<Section title="Weekly Schedule" icon={Clock}>
<div className="grid gap-2 sm:grid-cols-5">
{['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'].map((day) => {
const type = (c.weeklySchedule as any)?.[day] || 'OFF';
return (
<div key={day} className="bg-muted/30 rounded-lg p-3 text-center">
<p className="text-xs text-muted-foreground capitalize">{day}</p>
<p className="text-sm font-medium mt-1">
{type === 'IN_OFFICE' ? '🏢 Office' : type === 'REMOTE' ? '🏠 Remote' : '❌ Off'}
</p>
</div>
);
})}
</div>
</Section>
)}
{/* Salary Set Modal */}
{showSalaryModal && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setShowSalaryModal(false)}>
<div className="bg-card rounded-xl border p-6 max-w-md w-full space-y-4" onClick={e => e.stopPropagation()}>
<h3 className="font-semibold">Set Actual Salary</h3>
<p className="text-xs text-muted-foreground">Current: {c.actualSalaryPiasters ? formatEgp(c.actualSalaryPiasters) : 'Not set'} · Base: {c.baseSalaryPiasters ? formatEgp(c.baseSalaryPiasters) : '—'}</p>
<div className="space-y-1">
<label className="text-sm font-medium">New Salary (EGP)</label>
<input type="number" value={newSalary} onChange={e => setNewSalary(e.target.value)} placeholder="e.g. 12000" 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">Reason (optional)</label>
<input type="text" value={salaryReason} onChange={e => setSalaryReason(e.target.value)} placeholder="Reason for change" 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="flex justify-end gap-2">
<button onClick={() => setShowSalaryModal(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleSetSalary} className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90">Set Salary</button>
</div>
</div>
</div>
)}
</div>
);
}
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
return (
<div className="bg-card rounded-xl border p-4 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Icon size={16} /> {title}</h3>
{children}
</div>
);
}
function InfoGrid({ items }: { items: [string, string][] }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map(([label, value]) => (
<div key={label}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-0.5">{value}</p>
</div>
))}
</div>
);
}
function StatCard({ icon: Icon, label, value, sub, color }: { icon: React.ElementType; label: string; value: string | number; sub?: string; color?: string }) {
return (
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Icon size={14} />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<p className={`text-2xl font-bold ${color || ''}`}>{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiDelete } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { StatusBadge } from '@/components/shared/status-badge';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { formatDate } from '@/lib/date';
import { formatEgp, cn } from '@/lib/utils';
import {
Users, Kanban, FileText, AlertTriangle, DollarSign, Star,
Calendar, Bell, BookOpen, Shield, Webhook, Key, Search, Trash2,
ChevronLeft, ChevronRight, Database,
} from 'lucide-react';
import { toast } from 'sonner';
const ENTITIES = [
{ key: 'users', label: 'Users', icon: Users, endpoint: '/users', columns: ['firstName', 'lastName', 'username', 'role', 'status'] },
{ key: 'boards', label: 'Boards', icon: Kanban, endpoint: '/boards', columns: ['name', 'key', 'memberCount', 'isArchived'] },
{ key: 'cards', label: 'Cards', icon: FileText, endpoint: '/cards', params: { limit: 20 }, columns: ['cardNumber', 'title', 'priority', 'isArchived'] },
{ key: 'deductions', label: 'Deductions', icon: AlertTriangle, endpoint: '/deductions', columns: ['category', 'subCategory', 'status', 'amountPiasters'] },
{ key: 'adjustments', label: 'Adjustments', icon: DollarSign, endpoint: '/adjustments', columns: ['type', 'category', 'amountPiasters', 'status'] },
{ key: 'evaluations', label: 'Evaluations', icon: Star, endpoint: '/evaluations', columns: ['month', 'year', 'overallScore', 'status'] },
{ key: 'pips', label: 'PIPs', icon: AlertTriangle, endpoint: '/pips', columns: ['status', 'startDate', 'endDate'] },
{ key: 'holidays', label: 'Holidays', icon: Calendar, endpoint: '/holidays', columns: ['name', 'startDate', 'endDate', 'isRecurring'] },
{ key: 'notices', label: 'Notices', icon: Bell, endpoint: '/notices', columns: ['title', 'type', 'isBlocking'] },
{ key: 'policies', label: 'Policies', icon: BookOpen, endpoint: '/policies', columns: ['title', 'version', 'requiresAcknowledgment'] },
{ key: 'api-keys', label: 'API Keys', icon: Key, endpoint: '/api-keys', columns: ['name', 'scope', 'isActive'] },
{ key: 'webhooks', label: 'Webhooks', icon: Webhook, endpoint: '/webhooks', columns: ['url', 'isActive'] },
{ key: 'audit', label: 'Audit Trail', icon: Shield, endpoint: '/audit-trail', columns: ['action', 'entityType', 'method', 'ipAddress'] },
];
export default function ControlPanelPage() {
const [activeEntity, setActiveEntity] = useState(ENTITIES[0]);
const [data, setData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [deleteTarget, setDeleteTarget] = useState<any>(null);
useEffect(() => {
loadData();
}, [activeEntity, page, search]);
const loadData = async () => {
setIsLoading(true);
try {
const params: any = { page, limit: 20, ...(activeEntity.params || {}) };
if (search) params.search = search;
const res = await apiGet(activeEntity.endpoint, params);
setData(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load data:', err);
setData([]);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await apiDelete(`${activeEntity.endpoint}/${deleteTarget.id}`);
toast.success('Record deleted');
setDeleteTarget(null);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
const renderCellValue = (row: any, col: string): string => {
const val = row[col];
if (val === null || val === undefined) return '—';
if (typeof val === 'boolean') return val ? '✅' : '❌';
if (col.includes('Piasters') || col.includes('piasters')) return formatEgp(val);
if (col.includes('Date') || col.includes('At')) {
try { return formatDate(val); } catch { return String(val); }
}
if (typeof val === 'object') return JSON.stringify(val).slice(0, 50);
return String(val);
};
return (
<div className="space-y-6">
<PageHeader
title="Control Panel"
description="Super Admin — Full entity management (god mode)"
actions={
<div className="flex items-center gap-2">
<Database size={16} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{total} records</span>
</div>
}
/>
{/* Entity Tabs */}
<div className="flex gap-1 overflow-x-auto pb-2">
{ENTITIES.map(entity => {
const Icon = entity.icon;
const isActive = activeEntity.key === entity.key;
return (
<button
key={entity.key}
onClick={() => { setActiveEntity(entity); setPage(1); setSearch(''); }}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'hover:bg-accent text-muted-foreground',
)}
>
<Icon size={14} />
{entity.label}
</button>
);
})}
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder={`Search ${activeEntity.label.toLowerCase()}...`}
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
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>
{/* Table */}
{isLoading ? (
<PageLoadingSkeleton />
) : (
<div className="bg-card rounded-xl border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-16">ID</th>
{activeEntity.columns.map(col => (
<th key={col} className="text-left px-4 py-3 font-medium text-muted-foreground capitalize">
{col.replace(/([A-Z])/g, ' $1').trim()}
</th>
))}
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{data.map((row) => (
<tr key={row.id} className="hover:bg-accent/50">
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">{row.id?.slice(0, 8)}...</td>
{activeEntity.columns.map(col => (
<td key={col} className="px-4 py-3 text-xs max-w-[200px] truncate">
{col === 'status' || col === 'role' || col === 'priority' || col === 'type' || col === 'category' ? (
<StatusBadge status={row[col] || '—'} />
) : (
renderCellValue(row, col)
)}
</td>
))}
<td className="px-4 py-3">
{activeEntity.key !== 'audit' && (
<button onClick={() => setDeleteTarget(row)} className="p-1 text-muted-foreground hover:text-destructive">
<Trash2 size={14} />
</button>
)}
</td>
</tr>
))}
{data.length === 0 && (
<tr>
<td colSpan={activeEntity.columns.length + 2} className="text-center py-12 text-muted-foreground">
No {activeEntity.label.toLowerCase()} found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<span className="text-xs text-muted-foreground">Page {page} of {Math.ceil(total / 20)}</span>
<div className="flex gap-2">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50">
<ChevronLeft size={14} />
</button>
<button onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(total / 20)} className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50">
<ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title={`Delete ${activeEntity.label.slice(0, -1)}`}
description={`Permanently delete this record? This action cannot be undone.`}
confirmLabel="Delete"
destructive
/>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPost } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { formatEgp } from '@/lib/utils';
import { toast } from 'sonner';
import { Send, Loader2 } from 'lucide-react';
const CATEGORIES = [
{ value: 'A', label: 'A — Deadline Violations', subs: [
{ value: 'A1', label: 'A1 — Slight Delay (1-3 days)' },
{ value: 'A2', label: 'A2 — Moderate Delay (4-7 days)' },
{ value: 'A3', label: 'A3 — Severe Delay (8-14 days)' },
{ value: 'A4', label: 'A4 — Critical Delay (15+ days)' },
{ value: 'A5', label: 'A5 — Complete Failure' },
]},
{ value: 'B', label: 'B — Reporting Violations', subs: [
{ value: 'B1', label: 'B1 — Late Report' },
{ value: 'B2', label: 'B2 — Unreported Day' },
{ value: 'B3', label: 'B3 — Vague/Useless Report' },
{ value: 'B4', label: 'B4 — Falsified Report' },
]},
{ value: 'C', label: 'C — Quality Violations', subs: [
{ value: 'C1', label: 'C1 — Minor Quality Issues' },
{ value: 'C2', label: 'C2 — Significant Quality Issues' },
{ value: 'C3', label: 'C3 — Critical Quality Issues' },
{ value: 'C4', label: 'C4 — Regression' },
]},
{ value: 'D', label: 'D — Communication Violations', subs: [
{ value: 'D1', label: 'D1 — Slow Response' },
{ value: 'D2', label: 'D2 — No-Show Meeting' },
{ value: 'D3', label: 'D3 — Disappeared' },
{ value: 'D4', label: 'D4 — Unprofessional Conduct' },
]},
];
export default function CreateDeductionPage() {
const router = useRouter();
const [contractors, setContractors] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({
userId: '',
category: 'A',
subCategory: 'A1',
cardId: '',
violationDate: new Date().toISOString().split('T')[0],
description: '',
amountPiasters: 0,
});
useEffect(() => {
apiGet('/users', { role: 'CONTRACTOR', status: 'ACTIVE', limit: 100 })
.then(res => setContractors(res.data || []))
.catch(console.error);
}, []);
const selectedCat = CATEGORIES.find(c => c.value === form.category);
const handleSubmit = async () => {
if (!form.userId) { toast.error('Select a contractor'); return; }
if (form.description.length < 100) { toast.error('Description must be at least 100 characters'); return; }
setIsSubmitting(true);
try {
await apiPost('/deductions', {
userId: form.userId,
category: form.category,
subCategory: form.subCategory,
cardId: form.cardId || undefined,
violationDate: form.violationDate,
description: form.description,
amountPiasters: form.amountPiasters > 0 ? form.amountPiasters : undefined,
});
toast.success('Deduction created');
router.push('/admin/deductions');
} catch (err: any) {
toast.error(err.message || 'Failed to create deduction');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-3xl mx-auto space-y-6">
<PageHeader title="Create Deduction" description="Initiate a new deduction for a contractor" />
<div className="bg-card rounded-xl border p-6 space-y-5">
{/* Contractor */}
<div className="space-y-1">
<label className="text-sm font-medium">Contractor *</label>
<select value={form.userId} onChange={e => setForm({ ...form, userId: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="">Select contractor</option>
{contractors.map(c => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} (@{c.username})</option>
))}
</select>
</div>
{/* Category */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Category *</label>
<select value={form.category} onChange={e => {
const cat = e.target.value;
const firstSub = CATEGORIES.find(c => c.value === cat)?.subs[0]?.value || cat + '1';
setForm({ ...form, category: cat, subCategory: firstSub });
}} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Sub-Category *</label>
<select value={form.subCategory} onChange={e => setForm({ ...form, subCategory: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
{selectedCat?.subs.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{/* Date + Amount */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Violation Date *</label>
<input type="date" value={form.violationDate} onChange={e => setForm({ ...form, violationDate: e.target.value })} max={new Date().toISOString().split('T')[0]} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Amount (piasters) — 0 = auto-calculate</label>
<input type="number" value={form.amountPiasters} onChange={e => setForm({ ...form, amountPiasters: Number(e.target.value) })} min={0} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
{form.amountPiasters > 0 && <p className="text-xs text-muted-foreground">{formatEgp(form.amountPiasters)}</p>}
</div>
</div>
{/* Description */}
<div className="space-y-1">
<label className="text-sm font-medium">Description * (min 100 chars)</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={5} placeholder="Detailed explanation of the violation..." 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" />
<p className="text-xs text-muted-foreground">{form.description.length}/100 min characters</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<button onClick={() => router.back()} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleSubmit} disabled={isSubmitting} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Create Deduction
</button>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, 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 { StatusBadge } from '@/components/shared/status-badge';
import { formatDate, relativeTime } from '@/lib/date';
import { Send, Plus, Copy, Trash2, Loader2, UserPlus } from 'lucide-react';
import { toast } from 'sonner';
export default function InvitesPage() {
const [invites, setInvites] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [form, setForm] = useState({
contractorType: 'FULL_TIME',
expiresInDays: 7,
welcomeNote: '',
});
useEffect(() => { loadInvites(); }, []);
const loadInvites = async () => {
try {
const res = await apiGet('/onboarding/invites', { limit: 100 });
setInvites(res.data || []);
} catch (err) {
console.error('Failed to load invites:', err);
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
setIsCreating(true);
try {
const res = await apiPost('/onboarding/invites', form);
const code = res.data?.code || res.data?.inviteCode;
toast.success(`Invite created: ${code}`, { duration: 15000 });
setShowCreate(false);
loadInvites();
} catch (err: any) {
toast.error(err.message || 'Failed to create invite');
} finally {
setIsCreating(false);
}
};
const handleRevoke = async (id: string) => {
try {
await apiDelete(`/onboarding/invites/${id}`);
toast.success('Invite revoked');
loadInvites();
} catch (err: any) {
toast.error(err.message || 'Failed to revoke');
}
};
const copyLink = (code: string) => {
const url = `${window.location.origin}/register/${code}`;
navigator.clipboard.writeText(url);
toast.success('Invite link copied!');
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Invite Management"
description="Create and manage contractor invitations"
actions={
<button onClick={() => setShowCreate(true)} 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} /> Create Invite
</button>
}
/>
{showCreate && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">New Invitation</h3>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1">
<label className="text-sm font-medium">Contractor Type *</label>
<select value={form.contractorType} onChange={e => setForm({ ...form, contractorType: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="FULL_TIME">Full-Timer</option>
<option value="INTERN">Intern</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Expires In (days)</label>
<input type="number" value={form.expiresInDays} onChange={e => setForm({ ...form, expiresInDays: Number(e.target.value) })} min={1} max={30} 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">Welcome Note</label>
<input type="text" value={form.welcomeNote} onChange={e => setForm({ ...form, welcomeNote: e.target.value })} placeholder="Optional message" 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={() => setShowCreate(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCreate} disabled={isCreating} 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">
{isCreating ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Create
</button>
</div>
</div>
)}
{invites.length === 0 ? (
<EmptyState icon={UserPlus} title="No invitations" description="Create an invite to onboard a new contractor." />
) : (
<div className="bg-card rounded-xl border divide-y">
{invites.map(inv => (
<div key={inv.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-muted px-2 py-0.5 rounded">{inv.code || inv.inviteCode}</code>
<StatusBadge status={inv.status || 'ACTIVE'} />
<span className="text-xs text-muted-foreground">{inv.contractorType?.replace('_', ' ')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Created {inv.createdAt ? relativeTime(inv.createdAt) : '—'}
{inv.expiresAt && ` · Expires ${formatDate(inv.expiresAt)}`}
{inv.usedBy && ` · Used by ${inv.usedBy.firstName} ${inv.usedBy.lastName}`}
</p>
</div>
<div className="flex gap-1">
{(inv.status === 'ACTIVE' || !inv.status) && (
<>
<button onClick={() => copyLink(inv.code || inv.inviteCode)} className="p-2 text-muted-foreground hover:text-foreground" title="Copy link"><Copy size={14} /></button>
<button onClick={() => handleRevoke(inv.id)} className="p-2 text-muted-foreground hover:text-destructive" title="Revoke"><Trash2 size={14} /></button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState, useMemo } from 'react';
import { useParams } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { BoardHeader } from '@/components/kanban/board-header';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatEgp, cn } from '@/lib/utils';
import { ChevronLeft, ChevronRight, Coins, Clock } from 'lucide-react';
import { toast } from 'sonner';
export default function BoardCalendarPage() {
const { boardId } = useParams<{ boardId: string }>();
const [board, setBoard] = useState<any>(null);
const [cards, setCards] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
loadData();
}, [boardId]);
const loadData = async () => {
try {
const [boardRes, cardsRes] = await Promise.all([
apiGet(`/boards/${boardId}`),
apiGet('/cards', { boardId, limit: 500, isArchived: false }),
]);
setBoard(boardRes.data);
setCards(cardsRes.data || []);
} catch (err: any) {
toast.error(err.message || 'Failed to load board');
} finally {
setIsLoading(false);
}
};
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDayOfWeek = firstDay.getDay();
const totalDays = lastDay.getDate();
const days: { date: Date | null; cards: any[] }[] = [];
// Padding for days before the 1st
for (let i = 0; i < startDayOfWeek; i++) {
days.push({ date: null, cards: [] });
}
// Days of the month
for (let d = 1; d <= totalDays; d++) {
const date = new Date(year, month, d);
const dateStr = date.toISOString().split('T')[0];
const dayCards = cards.filter(card => {
if (!card.dueDate) return false;
const dueDateStr = new Date(card.dueDate).toISOString().split('T')[0];
return dueDateStr === dateStr;
});
days.push({ date, cards: dayCards });
}
return days;
}, [cards, year, month]);
const prevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
const nextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
const today = new Date();
const isToday = (date: Date | null) => date && date.toDateString() === today.toDateString();
const isPast = (date: Date | null) => date && date < today && !isToday(date);
if (isLoading) return <PageLoadingSkeleton />;
if (!board) return <div className="p-6 text-muted-foreground">Board not found.</div>;
return (
<div className="space-y-4">
<BoardHeader board={board} onRefresh={loadData} />
{/* Month Navigation */}
<div className="flex items-center justify-between">
<button onClick={prevMonth} className="p-2 rounded-md hover:bg-accent"><ChevronLeft size={16} /></button>
<h2 className="text-lg font-bold">
{currentDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
</h2>
<button onClick={nextMonth} className="p-2 rounded-md hover:bg-accent"><ChevronRight size={16} /></button>
</div>
{/* Calendar Grid */}
<div className="bg-card rounded-xl border overflow-hidden">
{/* Day Headers */}
<div className="grid grid-cols-7 border-b">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="px-2 py-2 text-xs font-medium text-muted-foreground text-center bg-muted/50">
{day}
</div>
))}
</div>
{/* Calendar Cells */}
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => (
<div
key={i}
className={cn(
'min-h-[100px] border-b border-r p-1.5 transition-colors',
!day.date && 'bg-muted/20',
isToday(day.date) && 'bg-primary/5',
isPast(day.date) && 'opacity-60',
)}
>
{day.date && (
<>
<div className={cn(
'text-xs font-medium mb-1',
isToday(day.date) && 'text-primary font-bold',
)}>
{day.date.getDate()}
</div>
<div className="space-y-0.5">
{day.cards.slice(0, 3).map(card => {
const isOverdue = !card.completedAt && new Date(card.dueDate) < today;
return (
<div
key={card.id}
className={cn(
'text-[10px] px-1.5 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity',
isOverdue
? 'bg-red-500/10 text-red-600'
: card.completedAt
? 'bg-emerald-500/10 text-emerald-600 line-through'
: 'bg-blue-500/10 text-blue-600',
)}
title={`${card.cardNumber}: ${card.title}`}
>
<span className="font-mono">{card.cardNumber}</span> {card.title}
</div>
);
})}
{day.cards.length > 3 && (
<div className="text-[10px] text-muted-foreground px-1.5">
+{day.cards.length - 3} more
</div>
)}
</div>
</>
)}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-blue-500/20" /> Due</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-red-500/20" /> Overdue</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-emerald-500/20" /> Completed</span>
</div>
</div>
);
}
\ No newline at end of file
......@@ -5,7 +5,10 @@ import { Bell, MessageSquare, Search, LogOut, User, Moon, Sun } from 'lucide-rea
import { useTheme } from 'next-themes';
import { useAuthStore } from '@/stores/auth.store';
import { useNotificationStore } from '@/stores/notification.store';
import { useSearchStore } from '@/stores/search.store';
import { HudBar } from '@/components/hud/hud-bar';
import { CommandPalette } from '@/components/search/command-palette';
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
import { cn } from '@/lib/utils';
export function Topbar() {
......@@ -13,10 +16,15 @@ export function Topbar() {
const { user, logout } = useAuthStore();
const { unreadCount } = useNotificationStore();
const { theme, setTheme } = useTheme();
const { isOpen, openSearch, closeSearch, toggleSearch } = useSearchStore();
const isContractor = user?.role === 'CONTRACTOR';
// Ctrl+K / Cmd+K to open search
useKeyboardShortcut('k', () => toggleSearch(), { ctrl: true });
return (
<>
<header className="sticky top-0 z-30 h-14 bg-background/80 backdrop-blur-sm border-b flex items-center justify-between px-6">
{/* Left: HUD (for contractors) or search */}
<div className="flex items-center gap-4 flex-1">
......@@ -24,17 +32,31 @@ export function Topbar() {
<HudBar />
) : (
<button
onClick={() => router.push('/admin/analytics')}
onClick={openSearch}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Search size={16} />
<span className="hidden sm:inline">Search... (Ctrl+K)</span>
<span className="hidden sm:inline">Search... </span>
<kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] bg-muted rounded border font-mono">
⌘K
</kbd>
</button>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1">
{/* Search button for contractors too */}
{isContractor && (
<button
onClick={openSearch}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Search (Ctrl+K)"
>
<Search size={18} />
</button>
)}
{/* Theme toggle */}
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
......@@ -86,5 +108,9 @@ export function Topbar() {
</div>
</div>
</header>
{/* Command Palette */}
<CommandPalette open={isOpen} onClose={closeSearch} />
</>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import {
Search, Kanban, ListTodo, FileText, Users, DollarSign,
MessageSquare, Bell, Settings, User, X, Loader2,
Star, Calendar, GraduationCap,
} from 'lucide-react';
const QUICK_ACTIONS = [
{ label: 'Submit Report', href: '/reports/submit', icon: FileText, keywords: ['report', 'submit', 'daily'] },
{ label: 'My Tasks', href: '/my-tasks', icon: ListTodo, keywords: ['tasks', 'my tasks', 'assigned'] },
{ label: 'Boards', href: '/boards', icon: Kanban, keywords: ['boards', 'kanban', 'projects'] },
{ label: 'Messages', href: '/messages', icon: MessageSquare, keywords: ['messages', 'dm', 'chat'] },
{ label: 'Notifications', href: '/notifications', icon: Bell, keywords: ['notifications', 'alerts'] },
{ label: 'Salary', href: '/salary', icon: DollarSign, keywords: ['salary', 'pay', 'money', 'hud'] },
{ label: 'Profile', href: '/profile', icon: User, keywords: ['profile', 'me', 'settings'] },
{ label: 'Directory', href: '/directory', icon: Users, keywords: ['directory', 'people', 'team'] },
{ label: 'Schedule', href: '/schedule', icon: Calendar, keywords: ['schedule', 'calendar'] },
{ label: 'Evaluations', href: '/evaluations', icon: Star, keywords: ['evaluations', 'eval', 'review'] },
{ label: 'Learning', href: '/learning', icon: GraduationCap, keywords: ['learning', 'goals', 'competency'] },
];
const ADMIN_ACTIONS = [
{ label: 'Contractors', href: '/admin/contractors', icon: Users, keywords: ['contractors', 'manage', 'admin'] },
{ label: 'Deductions', href: '/admin/deductions', icon: DollarSign, keywords: ['deductions', 'admin'] },
{ label: 'Payroll', href: '/admin/payroll', icon: DollarSign, keywords: ['payroll', 'payment'] },
{ label: 'Analytics', href: '/admin/analytics', icon: Star, keywords: ['analytics', 'reports', 'dashboard'] },
{ label: 'Settings', href: '/admin/settings', icon: Settings, keywords: ['settings', 'config'] },
{ label: 'Audit Trail', href: '/admin/audit-trail', icon: FileText, keywords: ['audit', 'trail', 'log'] },
{ label: 'Invites', href: '/admin/invites', icon: Users, keywords: ['invites', 'onboarding'] },
];
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
}
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const router = useRouter();
const user = useAuthStore(s => s.user);
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
// Reset on open
useEffect(() => {
if (open) {
setQuery('');
setSearchResults([]);
setSelectedIndex(0);
}
}, [open]);
// Search API debounced
useEffect(() => {
if (query.length < 2) { setSearchResults([]); return; }
const timer = setTimeout(async () => {
setIsSearching(true);
try {
const res = await apiGet('/search', { q: query, limit: 10 });
setSearchResults(res.data || []);
} catch { /* ok */ }
finally { setIsSearching(false); }
}, 300);
return () => clearTimeout(timer);
}, [query]);
// Filtered quick actions
const q = query.toLowerCase();
const filteredActions = [...QUICK_ACTIONS, ...(isAdmin ? ADMIN_ACTIONS : [])].filter(action =>
!query || action.label.toLowerCase().includes(q) || action.keywords.some(k => k.includes(q))
);
// All items for keyboard nav
const allItems = [
...filteredActions.map(a => ({ type: 'action' as const, ...a })),
...searchResults.map(r => ({ type: 'result' as const, ...r })),
];
const handleSelect = (item: any) => {
if (item.type === 'action') {
router.push(item.href);
} else if (item.type === 'result') {
if (item.entityType === 'cards') router.push(`/boards/${item.boardId || ''}`);
else if (item.entityType === 'users') router.push(`/admin/contractors/${item.id}`);
else if (item.entityType === 'boards') router.push(`/boards/${item.id}`);
else if (item.href) router.push(item.href);
}
onClose();
};
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, allItems.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && allItems[selectedIndex]) {
e.preventDefault();
handleSelect(allItems[selectedIndex]);
} else if (e.key === 'Escape') {
onClose();
}
}, [allItems, selectedIndex, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-start justify-center pt-[15vh] p-4" onClick={onClose}>
<div className="bg-card rounded-xl border shadow-2xl max-w-xl w-full overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Input */}
<div className="flex items-center gap-3 px-4 border-b">
<Search size={18} className="text-muted-foreground shrink-0" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
placeholder="Search or type a command..."
autoFocus
className="w-full py-3.5 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{isSearching && <Loader2 size={16} className="animate-spin text-muted-foreground" />}
<button onClick={onClose} className="p-1 text-muted-foreground hover:text-foreground">
<X size={16} />
</button>
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto">
{/* Quick Actions */}
{filteredActions.length > 0 && (
<div className="p-2">
<p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{query ? 'Matching Actions' : 'Quick Actions'}
</p>
{filteredActions.map((action, i) => {
const Icon = action.icon;
const isSelected = i === selectedIndex;
return (
<button
key={action.href}
onClick={() => handleSelect({ type: 'action', ...action })}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<Icon size={16} className="text-muted-foreground shrink-0" />
<span>{action.label}</span>
</button>
);
})}
</div>
)}
{/* API Results */}
{searchResults.length > 0 && (
<div className="p-2 border-t">
<p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Search Results</p>
{searchResults.map((result, i) => {
const globalIndex = filteredActions.length + i;
const isSelected = globalIndex === selectedIndex;
return (
<button
key={`${result.entityType}-${result.id}`}
onClick={() => handleSelect({ type: 'result', ...result })}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground shrink-0">
{result.entityType}
</span>
<div className="min-w-0">
<p className="truncate font-medium">{result.title || result.name || result.cardNumber || result.id?.slice(0, 8)}</p>
{result.snippet && <p className="text-xs text-muted-foreground truncate">{result.snippet}</p>}
</div>
</button>
);
})}
</div>
)}
{query.length >= 2 && searchResults.length === 0 && !isSearching && (
<p className="text-center py-8 text-sm text-muted-foreground">No results for "{query}"</p>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 border-t text-[10px] text-muted-foreground flex items-center gap-4">
<span>↑↓ Navigate</span>
<span>↵ Select</span>
<span>Esc Close</span>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useCallback } from 'react';
type KeyHandler = (event: KeyboardEvent) => void;
interface ShortcutOptions {
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
enabled?: boolean;
}
export function useKeyboardShortcut(
key: string,
handler: KeyHandler,
options: ShortcutOptions = {},
): void {
const { ctrl, meta, shift, alt, enabled = true } = options;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
// Don't fire shortcuts when typing in inputs
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
// Exception: allow Escape and Ctrl/Cmd+K even in inputs
const isEscape = event.key === 'Escape';
const isCmdK = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k';
if (!isEscape && !isCmdK) return;
}
const keyMatch = event.key.toLowerCase() === key.toLowerCase();
const ctrlMatch = ctrl ? (event.ctrlKey || event.metaKey) : true;
const metaMatch = meta ? event.metaKey : true;
const shiftMatch = shift ? event.shiftKey : !event.shiftKey;
const altMatch = alt ? event.altKey : !event.altKey;
// For Ctrl/Cmd shortcuts, require the modifier
if ((ctrl || meta) && !(event.ctrlKey || event.metaKey)) return;
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
event.preventDefault();
handler(event);
}
},
[key, handler, ctrl, meta, shift, alt, enabled],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
\ No newline at end of file
import { create } from 'zustand';
interface SearchState {
isOpen: boolean;
openSearch: () => void;
closeSearch: () => void;
toggleSearch: () => void;
}
export const useSearchStore = create<SearchState>((set) => ({
isOpen: false,
openSearch: () => set({ isOpen: true }),
closeSearch: () => set({ isOpen: false }),
toggleSearch: () => set((state) => ({ isOpen: !state.isOpen })),
}));
\ 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