Quran_Tech_Server / F_Pro /src /components /admin /SheikhApproval.tsx
aboalaa147's picture
Initial deployment
eb6a2f9
Raw
History Blame Contribute Delete
25.9 kB
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Textarea } from '../ui/textarea';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Alert, AlertDescription } from '../ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import {
CheckCircle2, XCircle, Calendar, Award, MapPin,
Loader2, RefreshCw, Clock, Video,
} from 'lucide-react';
import adminApi, { type PendingSheikhDto } from '../../lib/api/adminApi';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useLanguage } from '../lib/languageContext';
import { translations } from '../../lib/translations';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ScheduledInterview {
sheikhId: number;
name: string;
country: string;
specializations: string;
experienceYears: number;
scheduledAt: Date | null;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatCountdown(ms: number): string {
if (ms <= 0) return 'Now';
const totalSecs = Math.floor(ms / 1000);
const days = Math.floor(totalSecs / 86400);
const hours = Math.floor((totalSecs % 86400) / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
if (days > 0) return `${days}d ${hours}h ${mins}m`;
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
return `${mins}m ${secs}s`;
}
function isWithin5Mins(scheduledAt: Date | null, now: Date): boolean {
if (!scheduledAt) return false;
const diff = scheduledAt.getTime() - now.getTime();
return diff <= 5 * 60 * 1000 && diff > -30 * 60 * 1000;
}
// ─── Countdown badge ──────────────────────────────────────────────────────────
function CountdownBadge({ scheduledAt, now }: { scheduledAt: Date | null; now: Date }) {
if (!scheduledAt) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 whitespace-nowrap">
<Clock className="h-3 w-3 shrink-0" /> No time set
</span>
);
}
const diff = scheduledAt.getTime() - now.getTime();
const imminent = diff <= 5 * 60 * 1000 && diff > 0;
const past = diff <= 0;
if (past) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700 whitespace-nowrap">
<Clock className="h-3 w-3 shrink-0" /> In progress
</span>
);
}
if (imminent) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700 animate-pulse whitespace-nowrap">
<Clock className="h-3 w-3 shrink-0" /> {formatCountdown(diff)}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 whitespace-nowrap">
<Clock className="h-3 w-3 shrink-0" /> {formatCountdown(diff)}
</span>
);
}
// ─── Under Review Card ────────────────────────────────────────────────────────
function UnderReviewCard({
interview,
now,
onApprove,
onReject,
onJoin,
processing,
isFocus = false,
t,
}: {
interview: ScheduledInterview;
now: Date;
onApprove: () => void;
onReject: () => void;
onJoin: () => void;
processing: boolean;
isFocus?: boolean;
t: ReturnType<typeof translations>[string]['sheikhApproval'];
}) {
const diff = interview.scheduledAt ? interview.scheduledAt.getTime() - now.getTime() : null;
const isLive = diff !== null && diff <= 0 && diff > -30 * 60 * 1000;
return (
<Card className={`transition-all duration-300 relative ${
isFocus
? 'border-2 border-emerald-500 shadow-xl shadow-emerald-100 ring-1 ring-emerald-400/30'
: isLive
? 'ring-2 ring-emerald-500 shadow-lg shadow-emerald-100'
: 'hover:shadow-md'
}`}>
{isFocus && (
<div className="bg-emerald-500 text-white text-xs font-bold py-1 px-3 rounded-full absolute -top-3 left-1/2 -translate-x-1/2 z-10 animate-bounce whitespace-nowrap">
{isLive ? t.interviewLiveNow : t.nextInterviewSoon}
</div>
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2 flex-wrap sm:flex-nowrap">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="w-10 h-10 sm:w-11 sm:h-11 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold text-xs sm:text-sm shrink-0">
{interview.name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div>
<CardTitle className="text-base">{interview.name}</CardTitle>
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />{interview.country}
<span>·</span>
<span className="flex items-center gap-0.5">
<Award className="h-3 w-3 shrink-0" />
{interview.experienceYears} {t.yearsExperience}
</span>
</div>
</div>
</div>
<div className="shrink-0 mt-1 sm:mt-0">
<CountdownBadge scheduledAt={interview.scheduledAt} now={now} />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{/* Prominent countdown for focused card */}
{isFocus && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4 flex flex-col items-center justify-center space-y-1 mb-2">
{isLive ? (
<div className="text-3xl font-black text-emerald-600 tracking-tight animate-pulse">LIVE NOW</div>
) : (
<>
<div className="text-emerald-600 text-[10px] uppercase tracking-wider font-semibold">Starts in</div>
<div className="text-3xl font-black text-emerald-700 tracking-tight tabular-nums">
{diff !== null ? formatCountdown(diff) : '—'}
</div>
</>
)}
</div>
)}
<p className="text-sm text-muted-foreground">{interview.specializations}</p>
{/* Interview time row */}
{interview.scheduledAt ? (
<div className="flex items-center gap-2 text-sm rounded-lg px-3 py-2 bg-blue-50 text-blue-800">
<Calendar className="h-4 w-4 shrink-0 text-blue-600" />
<span className="font-medium">
{interview.scheduledAt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
{' at '}
{interview.scheduledAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-sm rounded-lg px-3 py-2 text-slate-500 bg-slate-50">
<Calendar className="h-4 w-4 shrink-0" />
<span>{t.noInterviewTime}</span>
</div>
)}
{/* Action buttons — stack on mobile when 3 shown */}
<div className={`flex gap-2 pt-1 ${isLive ? 'flex-col sm:flex-row' : 'flex-row'}`}>
{isLive && (
<Button size="sm" className="w-full bg-emerald-600 hover:bg-emerald-700 font-bold" disabled={processing} onClick={onJoin}>
<Video className="mr-1.5 h-3.5 w-3.5" />
Join Interview
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SheikhApproval() {
const navigate = useNavigate();
const { language, isRTL } = useLanguage();
const t = translations[language].sheikhApproval;
const [pendingSheikhs, setPendingSheikhs] = useState<PendingSheikhDto[]>([]);
const [scheduledInterviews, setScheduledInterviews] = useState<ScheduledInterview[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(new Date());
const [focusOverride, setFocusOverride] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'under-review'>('pending');
const [selectedSheikh, setSelectedSheikh] = useState<number | null>(null);
const [showDialog, setShowDialog] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'interview'>('approve');
const [rejectionReason, setRejectionReason] = useState('');
const [interviewDate, setInterviewDate] = useState('');
const [interviewTime, setInterviewTime] = useState('');
const [processing, setProcessing] = useState(false);
// ── Tick every second ──────────────────────────────────────────────────────
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// ── Focus mode logic & auto-switch ─────────────────────────────────────────
const imminentInterview = scheduledInterviews.find(i => isWithin5Mins(i.scheduledAt, now));
const showFocusMode = !!imminentInterview && !focusOverride;
const [lastImminentId, setLastImminentId] = useState<number | null>(null);
useEffect(() => {
if (imminentInterview) {
if (imminentInterview.sheikhId !== lastImminentId) {
setLastImminentId(imminentInterview.sheikhId);
if (!focusOverride) setActiveTab('under-review');
}
} else {
setLastImminentId(null);
setFocusOverride(false);
}
}, [imminentInterview, lastImminentId, focusOverride]);
// ── Load data ──────────────────────────────────────────────────────────────
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [pending, underReview] = await Promise.all([
adminApi.fetchPendingSheikhs(),
adminApi.fetchSheikhsByStatus('UNDER_REVIEW'),
]);
setPendingSheikhs(pending);
const interviews: ScheduledInterview[] = underReview
.map(s => ({
sheikhId: s.id,
name: s.name,
country: s.country,
specializations: s.specializations,
experienceYears: s.experienceYears,
scheduledAt: s.interviewDateTime ? new Date(s.interviewDateTime) : null,
}))
.sort((a, b) => {
if (!a.scheduledAt && !b.scheduledAt) return 0;
if (!a.scheduledAt) return 1;
if (!b.scheduledAt) return -1;
return a.scheduledAt.getTime() - b.scheduledAt.getTime();
});
setScheduledInterviews(interviews);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(`${t.failedToLoad}: ${message}`);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => { loadData(); }, [loadData]);
// ── Actions ────────────────────────────────────────────────────────────────
const handleAction = (sheikhId: number, action: 'approve' | 'reject' | 'interview') => {
setSelectedSheikh(sheikhId);
setActionType(action);
setShowDialog(true);
};
const confirmAction = async () => {
if (selectedSheikh === null) return;
setProcessing(true);
try {
if (actionType === 'approve') {
await adminApi.approveSheikh(selectedSheikh);
} else if (actionType === 'reject') {
await adminApi.rejectSheikh(selectedSheikh, rejectionReason);
} else {
await adminApi.scheduleInterview(selectedSheikh, interviewDate, interviewTime);
}
setShowDialog(false);
setRejectionReason('');
setInterviewDate('');
setInterviewTime('');
await loadData();
} catch (err) {
console.error(`Failed to ${actionType} sheikh:`, err);
toast.error(`${t.failedToAction} ${actionType}. ${t.pleaseTryAgain}`);
} finally {
setProcessing(false);
}
};
// ── Pending card ───────────────────────────────────────────────────────────
const renderPendingCard = (sheikh: PendingSheikhDto) => (
<Card key={sheikh.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="w-10 h-10 sm:w-11 sm:h-11 rounded-full bg-emerald-600 flex items-center justify-center text-white font-semibold text-xs sm:text-sm shrink-0">
{sheikh.name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<CardTitle className="text-sm sm:text-base truncate">{sheikh.name}</CardTitle>
<div className="flex items-center gap-1 sm:gap-1.5 mt-0.5 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-0.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate max-w-[80px] sm:max-w-none">{sheikh.County}</span>
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Award className="h-3 w-3 shrink-0" />
{sheikh.experienceYears} {t.yearsExperience}
</span>
</div>
</div>
</div>
<Badge variant="secondary" className="shrink-0 text-xs">{t.pending}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<p className="text-xs sm:text-sm text-muted-foreground line-clamp-2">{sheikh.specialization}</p>
{/* Stacked buttons on mobile, row on sm+ */}
<div className="flex flex-col sm:flex-row gap-2 pt-1">
<Button
variant="outline"
className="flex-1 text-xs sm:text-sm"
onClick={() => handleAction(sheikh.id, 'interview')}
>
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
{t.scheduleInterview}
</Button>
<div className="flex gap-2 flex-1">
<Button
variant="outline"
className="flex-1 text-red-600 hover:bg-red-50 text-xs sm:text-sm"
onClick={() => handleAction(sheikh.id, 'reject')}
>
<XCircle className="mr-1 sm:mr-1.5 h-3.5 w-3.5 shrink-0" />
{t.reject}
</Button>
<Button
className="flex-1 text-xs sm:text-sm"
onClick={() => handleAction(sheikh.id, 'approve')}
>
<CheckCircle2 className="mr-1 sm:mr-1.5 h-3.5 w-3.5 shrink-0" />
{t.approve}
</Button>
</div>
</div>
</CardContent>
</Card>
);
// ── Dialog title/description helpers ───────────────────────────────────────
const dialogTitle = actionType === 'approve'
? t.approveApplication
: actionType === 'reject'
? t.rejectApplication
: t.scheduleInterview;
const dialogDescription = actionType === 'approve'
? t.approveDescription
: actionType === 'reject'
? t.rejectDescription
: t.scheduleDescription;
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className={`container mx-auto p-3 sm:p-6 ${isRTL ? 'rtl' : 'ltr'}`} dir={isRTL ? 'rtl' : 'ltr'}>
{/* Page header */}
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold">{t.sheikhApprovalManagement}</h1>
<p className="text-sm text-muted-foreground">{t.reviewManageApplications}</p>
</div>
{/* <Button variant="outline" onClick={loadData} disabled={loading} className="self-start sm:self-auto">
<RefreshCw className={`h-4 w-4 ${isRTL ? 'ml-2' : 'mr-2'} ${loading ? 'animate-spin' : ''}`} />
{t.refresh}
</Button> */}
</div>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<span className="text-sm">{error}</span>
<Button variant="outline" size="sm" onClick={loadData} className="shrink-0">{t.retry}</Button>
</AlertDescription>
</Alert>
)}
{loading ? (
<div className="flex justify-center items-center py-20">
<Loader2 className="h-10 w-10 animate-spin text-emerald-600" />
</div>
) : (
<Tabs value={activeTab} onValueChange={val => setActiveTab(val as 'pending' | 'under-review')}>
<TabsList className="mb-4 w-full sm:w-auto">
<TabsTrigger value="pending" className="flex-1 sm:flex-none text-xs sm:text-sm">
{t.pending}
{pendingSheikhs.length > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-orange-700 text-xs font-semibold">
{pendingSheikhs.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="under-review" className="flex-1 sm:flex-none text-xs sm:text-sm">
{t.interviewScheduled}
{scheduledInterviews.length > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-blue-700 text-xs font-semibold">
{scheduledInterviews.length}
</span>
)}
</TabsTrigger>
</TabsList>
{/* ── Pending tab ── */}
<TabsContent value="pending">
{pendingSheikhs.length > 0 ? (
<div className="grid gap-3 sm:gap-4 grid-cols-1 md:grid-cols-2">
{pendingSheikhs.map(renderPendingCard)}
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground text-sm">
{t.noPending}
</CardContent>
</Card>
)}
</TabsContent>
{/* ── Under Review tab ── */}
<TabsContent value="under-review">
{scheduledInterviews.length > 0 ? (
<div className="space-y-4 sm:space-y-6">
{showFocusMode && imminentInterview ? (
<div className="space-y-4 sm:space-y-6">
<div className="max-w-xl mx-auto px-0 sm:px-4">
<UnderReviewCard
key={imminentInterview.sheikhId}
interview={imminentInterview}
now={now}
processing={processing}
onApprove={() => handleAction(imminentInterview.sheikhId, 'approve')}
onReject={() => handleAction(imminentInterview.sheikhId, 'reject')}
onJoin={() => navigate(`/admin/interview/${imminentInterview.sheikhId}`)}
isFocus
t={t}
/>
</div>
<div className="text-center">
<Button
variant="outline"
onClick={() => setFocusOverride(true)}
className="text-slate-500 border border-slate-200 text-sm"
>
{t.showAllInterviews}
</Button>
</div>
</div>
) : (
<div className="space-y-4 sm:space-y-6">
<div className="grid gap-3 sm:gap-4 grid-cols-1 md:grid-cols-2">
{scheduledInterviews.map(interview => (
<UnderReviewCard
key={interview.sheikhId}
interview={interview}
now={now}
processing={processing}
onApprove={() => handleAction(interview.sheikhId, 'approve')}
onReject={() => handleAction(interview.sheikhId, 'reject')}
onJoin={() => navigate(`/admin/interview/${interview.sheikhId}`)}
isFocus={interview.sheikhId === imminentInterview?.sheikhId}
t={t}
/>
))}
</div>
{imminentInterview && (
<div className="text-center pt-2">
<Button
variant="ghost"
onClick={() => setFocusOverride(false)}
className="text-slate-500 text-sm"
>
{t.backToFocusMode}
</Button>
</div>
)}
</div>
)}
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground text-sm">
{t.noInterviewsScheduled}
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
)}
{/* ── Action Dialog ── */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md mx-auto" dir={isRTL ? 'rtl' : 'ltr'}>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{dialogTitle}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">{dialogDescription}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{actionType === 'reject' && (
<div className="space-y-2">
<Label htmlFor="reason" className="text-sm">{t.rejectionReason}</Label>
<Textarea
id="reason"
placeholder={t.rejectionPlaceholder}
value={rejectionReason}
onChange={e => setRejectionReason(e.target.value)}
rows={4}
className="text-sm"
/>
</div>
)}
{actionType === 'interview' && (
<>
<div className="space-y-2">
<Label htmlFor="date" className="text-sm">{t.interviewDate}</Label>
<Input id="date" type="date" value={interviewDate} onChange={e => setInterviewDate(e.target.value)} className="text-sm" />
</div>
<div className="space-y-2">
<Label htmlFor="time" className="text-sm">{t.interviewTime}</Label>
<Input id="time" type="time" value={interviewTime} onChange={e => setInterviewTime(e.target.value)} className="text-sm" />
</div>
</>
)}
{actionType === 'approve' && (
<Alert>
<AlertDescription className="text-xs sm:text-sm">{t.approveAlert}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowDialog(false)}
className="flex-1 text-sm"
disabled={processing}
>
{t.cancel}
</Button>
<Button
onClick={confirmAction}
className="flex-1 text-sm"
variant={actionType === 'reject' ? 'destructive' : 'default'}
disabled={
processing ||
(actionType === 'reject' && !rejectionReason) ||
(actionType === 'interview' && (!interviewDate || !interviewTime))
}
>
{processing ? <Loader2 className="h-4 w-4 animate-spin" /> : t.confirm}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}