3600 lines
182 KiB
TypeScript
3600 lines
182 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
ShieldCheck, Lock, User, LogOut, Mail, Plus, Trash2,
|
||
CheckCircle2, AlertCircle, RefreshCw, Bus, Send,
|
||
MessageCircle, BookOpen, Bot, Edit, MapPin,
|
||
Clock, Palette, KeyRound, ExternalLink, ArrowRight,
|
||
Code2, Check, HelpCircle, Users, Activity,
|
||
UserCheck, Building2, GraduationCap, Calculator, Search, GitMerge,
|
||
Power, Play, SlidersHorizontal, ToggleLeft, ToggleRight,
|
||
Eye, EyeOff, Zap, Copy, CheckCheck, Sparkles, Radio, Terminal,
|
||
MessageSquare, ShieldAlert
|
||
} from 'lucide-react';
|
||
import { getBackendUrl } from '@/lib/utils';
|
||
import { Link } from 'react-router-dom';
|
||
import { useAuth } from '@/context/AuthContext';
|
||
import { FACULTY_DATA, CLUBS_DATA, DEPARTMENTS } from '@/constants';
|
||
import { DEPARTMENT_CURRICULUM, DEPARTMENT_CODE_MAP } from '@/constants/departmentCurriculum';
|
||
|
||
|
||
interface BusStopItem {
|
||
id?: number;
|
||
name: string;
|
||
time: string;
|
||
stopOrder?: number;
|
||
}
|
||
|
||
interface BusRouteItem {
|
||
id: number;
|
||
number: string;
|
||
name: string;
|
||
from: string;
|
||
to: string;
|
||
departureTime: string;
|
||
arrivalTime: string;
|
||
color: string;
|
||
status?: 'ACTIVE' | 'STOPPED';
|
||
stops?: BusStopItem[];
|
||
}
|
||
|
||
interface SeniorHelperItem {
|
||
chatId: number;
|
||
name: string;
|
||
department?: string;
|
||
lastPingStatus?: 'success' | 'failed' | 'idle';
|
||
lastPingTime?: string;
|
||
}
|
||
|
||
interface TelegramBotInfo {
|
||
id?: number;
|
||
is_bot?: boolean;
|
||
first_name?: string;
|
||
username?: string;
|
||
can_join_groups?: boolean;
|
||
can_read_all_group_messages?: boolean;
|
||
}
|
||
|
||
interface TelegramConfig {
|
||
community_bot_token?: string;
|
||
telegram_bot_token?: string;
|
||
bot_username?: string;
|
||
spring_backend_url?: string;
|
||
helper_chat_ids?: number[];
|
||
seniorHelpers?: SeniorHelperItem[];
|
||
}
|
||
|
||
const ROUTE_STORAGE_KEY = 'RIT_LOCAL_BUS_ROUTES';
|
||
const TELEGRAM_STORAGE_KEY = 'RIT_LOCAL_TELEGRAM_CONFIG';
|
||
const NOTES_STORAGE_KEY = 'RIT_LOCAL_NOTES';
|
||
const QUESTIONS_STORAGE_KEY = 'RIT_LOCAL_QUESTIONS';
|
||
const RECIPIENTS_STORAGE_KEY = 'RIT_LOCAL_RECIPIENTS';
|
||
const FACULTY_STORAGE_KEY = 'RIT_LOCAL_FACULTY';
|
||
const CLUBS_STORAGE_KEY = 'RIT_LOCAL_CLUBS';
|
||
const CURRICULUM_STORAGE_KEY = 'RIT_LOCAL_GPA_CURRICULUM';
|
||
|
||
interface NoteItem {
|
||
id: number;
|
||
title: string;
|
||
subject: string;
|
||
department: string;
|
||
semester: number;
|
||
fileType: string;
|
||
downloadUrl: string;
|
||
}
|
||
|
||
interface QuestionItem {
|
||
id: number;
|
||
title: string;
|
||
body: string;
|
||
authorName: string;
|
||
authorEmail: string;
|
||
tags?: string[];
|
||
votes?: number;
|
||
createdAt?: string;
|
||
status?: string;
|
||
}
|
||
|
||
export default function AdminDashboard() {
|
||
const { user, loginWithCredentials, logout: authLogout } = useAuth();
|
||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||
const [userRole, setUserRole] = useState<'ROLE_TRANSPORT' | 'ROLE_SUPER_ADMIN' | null>(null);
|
||
const [usernameDisplay, setUsernameDisplay] = useState('Admin');
|
||
|
||
// Login State
|
||
const [usernameInput, setUsernameInput] = useState('');
|
||
const [passwordInput, setPasswordInput] = useState('');
|
||
const [loginError, setLoginError] = useState<string | null>(null);
|
||
const [loadingLogin, setLoadingLogin] = useState(false);
|
||
|
||
// Active Tab
|
||
const [activeTab, setActiveTab] = useState<'transport' | 'telegram' | 'notes' | 'community' | 'subscribers' | 'faculty' | 'clubs' | 'curriculum'>('transport');
|
||
|
||
// Faculty State
|
||
const [facultyList, setFacultyList] = useState<any[]>([]);
|
||
const [loadingFaculty, setLoadingFaculty] = useState(false);
|
||
const [facultySearch, setFacultySearch] = useState('');
|
||
const [isFacultyModalOpen, setIsFacultyModalOpen] = useState(false);
|
||
const [editingFaculty, setEditingFaculty] = useState<any | null>(null);
|
||
const [facultyFormData, setFacultyFormData] = useState({
|
||
name: '',
|
||
designation: 'Assistant Professor',
|
||
department: 'Computer Science & Engineering',
|
||
email: '',
|
||
office: '',
|
||
specialization: '',
|
||
isClassIncharge: false,
|
||
});
|
||
|
||
// Clubs & Centers State
|
||
const [clubsList, setClubsList] = useState<any[]>([]);
|
||
const [loadingClubs, setLoadingClubs] = useState(false);
|
||
const [clubsSearch, setClubsSearch] = useState('');
|
||
const [isClubModalOpen, setIsClubModalOpen] = useState(false);
|
||
const [editingClub, setEditingClub] = useState<any | null>(null);
|
||
const [clubFormData, setClubFormData] = useState({
|
||
name: '',
|
||
description: '',
|
||
category: 'Center of Excellence',
|
||
type: 'Center' as 'Club' | 'Center',
|
||
coordinatorName: '',
|
||
contactEmail: '',
|
||
details: '',
|
||
});
|
||
|
||
// Curriculum & GPA State
|
||
const [selectedGpaDept, setSelectedGpaDept] = useState<string>('CSE');
|
||
const [selectedGpaSem, setSelectedGpaSem] = useState<number>(1);
|
||
const [curriculumMap, setCurriculumMap] = useState<Record<string, Record<number, any[]>>>({});
|
||
const [isCourseModalOpen, setIsCourseModalOpen] = useState(false);
|
||
const [editingCourseIndex, setEditingCourseIndex] = useState<number | null>(null);
|
||
const [courseFormData, setCourseFormData] = useState({
|
||
name: '',
|
||
credits: 3,
|
||
isElective: false,
|
||
});
|
||
|
||
// Transport State
|
||
const [routes, setRoutes] = useState<BusRouteItem[]>([]);
|
||
const [loadingRoutes, setLoadingRoutes] = useState(false);
|
||
const [isRouteModalOpen, setIsRouteModalOpen] = useState(false);
|
||
const [editingRoute, setEditingRoute] = useState<BusRouteItem | null>(null);
|
||
const [routeFormData, setRouteFormData] = useState({
|
||
number: '',
|
||
name: '',
|
||
from: '',
|
||
to: '',
|
||
departureTime: '07:00 AM',
|
||
arrivalTime: '08:30 AM',
|
||
color: '#FF6B00',
|
||
stops: [{ name: '', time: '07:15 AM' }],
|
||
});
|
||
|
||
// Merge Routes State
|
||
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
|
||
const [selectedRouteIdsForMerge, setSelectedRouteIdsForMerge] = useState<number[]>([]);
|
||
const [mergedRouteFormData, setMergedRouteFormData] = useState({
|
||
number: '',
|
||
name: '',
|
||
from: '',
|
||
to: 'RIT Campus',
|
||
departureTime: '07:00 AM',
|
||
arrivalTime: '08:30 AM',
|
||
color: '#FF6B00',
|
||
deactivateOriginals: false,
|
||
stops: [] as { name: string; time: string; sourceRoute?: string }[],
|
||
});
|
||
|
||
// Bus Status Manager State
|
||
const [isBusStatusModalOpen, setIsBusStatusModalOpen] = useState(false);
|
||
const [busStatusTabFilter, setBusStatusTabFilter] = useState<'ALL' | 'ACTIVE' | 'STOPPED'>('ALL');
|
||
const [gridStatusFilter, setGridStatusFilter] = useState<'ALL' | 'ACTIVE' | 'STOPPED'>('ACTIVE');
|
||
|
||
// Telegram Q&A & Bot State
|
||
const [telegramConfig, setTelegramConfig] = useState<TelegramConfig | null>(null);
|
||
const [loadingTelegram, setLoadingTelegram] = useState(false);
|
||
const [newHelperChatId, setNewHelperChatId] = useState('');
|
||
const [newHelperName, setNewHelperName] = useState('');
|
||
const [newHelperDept, setNewHelperDept] = useState('CSE');
|
||
const [savingBotConfig, setSavingBotConfig] = useState(false);
|
||
const [showCommunityToken, setShowCommunityToken] = useState(false);
|
||
const [showCollabToken, setShowCollabToken] = useState(false);
|
||
const [testingBotType, setTestingBotType] = useState<'community' | 'collab' | null>(null);
|
||
const [botInfoCommunity, setBotInfoCommunity] = useState<TelegramBotInfo | null>(null);
|
||
const [botInfoCollab, setBotInfoCollab] = useState<TelegramBotInfo | null>(null);
|
||
const [pingingHelperId, setPingingHelperId] = useState<number | null>(null);
|
||
const [broadcastingAlert, setBroadcastingAlert] = useState(false);
|
||
const [copiedChatId, setCopiedChatId] = useState<number | null>(null);
|
||
const [editingHelper, setEditingHelper] = useState<SeniorHelperItem | null>(null);
|
||
const [editHelperName, setEditHelperName] = useState('');
|
||
const [editHelperDept, setEditHelperDept] = useState('CSE');
|
||
const [activeGuideTab, setActiveGuideTab] = useState<'botfather' | 'chatid' | 'workflow'>('botfather');
|
||
|
||
// Notes State
|
||
const [notes, setNotes] = useState<NoteItem[]>([]);
|
||
const [loadingNotes, setLoadingNotes] = useState(false);
|
||
|
||
// Community Questions State
|
||
const [questions, setQuestions] = useState<QuestionItem[]>([]);
|
||
const [loadingQuestions, setLoadingQuestions] = useState(false);
|
||
|
||
// Deployment Subscribers State
|
||
const [recipients, setRecipients] = useState<string[]>([]);
|
||
const [newEmail, setNewEmail] = useState('');
|
||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||
const [submittingEmail, setSubmittingEmail] = useState(false);
|
||
|
||
// Toast State
|
||
const [toastMessage, setToastMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||
|
||
const showToast = (type: 'success' | 'error', text: string) => {
|
||
setToastMessage({ type, text });
|
||
setTimeout(() => setToastMessage(null), 4000);
|
||
};
|
||
|
||
// Restore session from AuthContext or localStorage
|
||
useEffect(() => {
|
||
if (user?.role === 'ROLE_TRANSPORT' || user?.role === 'ROLE_SUPER_ADMIN') {
|
||
setIsLoggedIn(true);
|
||
setUserRole(user.role);
|
||
setUsernameDisplay(user.name);
|
||
if (user.role === 'ROLE_TRANSPORT') {
|
||
setActiveTab('transport');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const savedRole = localStorage.getItem('RIT_ADMIN_ROLE') as any;
|
||
const savedToken = localStorage.getItem('RIT_ADMIN_TOKEN');
|
||
if (savedToken && (savedRole === 'ROLE_TRANSPORT' || savedRole === 'ROLE_SUPER_ADMIN')) {
|
||
setIsLoggedIn(true);
|
||
setUserRole(savedRole);
|
||
setUsernameDisplay(localStorage.getItem('RIT_ADMIN_USER') || (savedRole === 'ROLE_TRANSPORT' ? 'Transport Admin' : 'Super Admin'));
|
||
if (savedRole === 'ROLE_TRANSPORT') {
|
||
setActiveTab('transport');
|
||
}
|
||
}
|
||
}, [user]);
|
||
|
||
// Fetch data when activeTab changes
|
||
useEffect(() => {
|
||
if (!isLoggedIn) return;
|
||
if (activeTab === 'transport') fetchRoutes();
|
||
if (activeTab === 'telegram') fetchTelegramConfig();
|
||
if (activeTab === 'community') fetchQuestions();
|
||
if (activeTab === 'faculty') fetchFaculty();
|
||
if (activeTab === 'clubs') fetchClubs();
|
||
if (activeTab === 'curriculum') fetchCurriculum();
|
||
}, [isLoggedIn, activeTab]);
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// AUTHENTICATION
|
||
// ─────────────────────────────────────────────────────────────
|
||
const handleLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!usernameInput.trim() || !passwordInput.trim()) {
|
||
setLoginError('Please enter both username and password.');
|
||
return;
|
||
}
|
||
setLoadingLogin(true);
|
||
setLoginError(null);
|
||
|
||
try {
|
||
const result = await loginWithCredentials(usernameInput.trim(), passwordInput.trim());
|
||
if (result.success && (result.role === 'ROLE_TRANSPORT' || result.role === 'ROLE_SUPER_ADMIN')) {
|
||
setIsLoggedIn(true);
|
||
setUserRole(result.role as any);
|
||
setUsernameDisplay(result.role === 'ROLE_TRANSPORT' ? 'Transport Admin' : 'Super Admin');
|
||
setActiveTab('transport');
|
||
} else {
|
||
setLoginError(result.message || 'Invalid admin credentials. Use Transport or Admin with password RIT@2026.');
|
||
}
|
||
} catch (err: any) {
|
||
setLoginError(err?.message || 'Authentication error.');
|
||
} finally {
|
||
setLoadingLogin(false);
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
localStorage.removeItem('RIT_ADMIN_TOKEN');
|
||
localStorage.removeItem('RIT_ADMIN_ROLE');
|
||
localStorage.removeItem('RIT_ADMIN_USER');
|
||
setIsLoggedIn(false);
|
||
setUserRole(null);
|
||
setUsernameInput('');
|
||
setPasswordInput('');
|
||
authLogout();
|
||
};
|
||
|
||
const [busSearchQuery, setBusSearchQuery] = useState('');
|
||
const ROUTE_STORAGE_KEY = 'RIT_LOCAL_BUS_ROUTES';
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TRANSPORT ROUTE HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const fetchRoutes = () => {
|
||
setLoadingRoutes(true);
|
||
|
||
// 1. Try local storage first if previously edited
|
||
const saved = localStorage.getItem(ROUTE_STORAGE_KEY);
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||
setRoutes(parsed);
|
||
setLoadingRoutes(false);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// 2. Fetch from backend or public/bus_routes.json
|
||
fetch(getBackendUrl('/api/admin/transport/routes'))
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
setRoutes(data);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(data));
|
||
} else {
|
||
// Fallback to static bus_routes.json if database was empty
|
||
fetch('/bus_routes.json')
|
||
.then((r) => r.json())
|
||
.then((staticData) => {
|
||
if (Array.isArray(staticData) && staticData.length > 0) {
|
||
const formatted: BusRouteItem[] = staticData.map((s: any, idx: number) => ({
|
||
id: idx + 1,
|
||
number: s.number,
|
||
name: s.name,
|
||
from: s.from,
|
||
to: s.to,
|
||
departureTime: s.departureTime,
|
||
arrivalTime: s.arrivalTime,
|
||
color: s.color || '#FF6B00',
|
||
stops: (s.stops || []).map((st: any, sIdx: number) => ({
|
||
id: sIdx + 1,
|
||
name: st.name,
|
||
time: st.time,
|
||
stopOrder: sIdx + 1,
|
||
})),
|
||
}));
|
||
setRoutes(formatted);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(formatted));
|
||
}
|
||
});
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Backend offline fallback
|
||
fetch('/bus_routes.json')
|
||
.then((r) => r.json())
|
||
.then((staticData) => {
|
||
if (Array.isArray(staticData) && staticData.length > 0) {
|
||
const formatted: BusRouteItem[] = staticData.map((s: any, idx: number) => ({
|
||
id: idx + 1,
|
||
number: s.number,
|
||
name: s.name,
|
||
from: s.from,
|
||
to: s.to,
|
||
departureTime: s.departureTime,
|
||
arrivalTime: s.arrivalTime,
|
||
color: s.color || '#FF6B00',
|
||
stops: (s.stops || []).map((st: any, sIdx: number) => ({
|
||
id: sIdx + 1,
|
||
name: st.name,
|
||
time: st.time,
|
||
stopOrder: sIdx + 1,
|
||
})),
|
||
}));
|
||
setRoutes(formatted);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(formatted));
|
||
}
|
||
});
|
||
})
|
||
.finally(() => setLoadingRoutes(false));
|
||
};
|
||
|
||
const openCreateRouteModal = () => {
|
||
setEditingRoute(null);
|
||
setRouteFormData({
|
||
number: '',
|
||
name: '',
|
||
from: '',
|
||
to: 'RIT Campus',
|
||
departureTime: '07:00 AM',
|
||
arrivalTime: '08:30 AM',
|
||
color: '#FF6B00',
|
||
stops: [
|
||
{ name: '', time: '07:00 AM' },
|
||
{ name: 'RIT Campus', time: '08:30 AM' },
|
||
],
|
||
});
|
||
setIsRouteModalOpen(true);
|
||
};
|
||
|
||
const openEditRouteModal = (route: BusRouteItem) => {
|
||
setEditingRoute(route);
|
||
setRouteFormData({
|
||
number: route.number,
|
||
name: route.name,
|
||
from: route.from,
|
||
to: route.to,
|
||
departureTime: route.departureTime,
|
||
arrivalTime: route.arrivalTime,
|
||
color: route.color || '#FF6B00',
|
||
stops: route.stops && route.stops.length > 0
|
||
? route.stops.map(s => ({ name: s.name, time: s.time }))
|
||
: [{ name: route.from, time: route.departureTime }, { name: route.to, time: route.arrivalTime }],
|
||
});
|
||
setIsRouteModalOpen(true);
|
||
};
|
||
|
||
const handleSaveRoute = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!routeFormData.number.trim() || !routeFormData.name.trim() || !routeFormData.from.trim()) {
|
||
showToast('error', 'Please fill in required route information.');
|
||
return;
|
||
}
|
||
|
||
const cleanedStops = routeFormData.stops
|
||
.filter(s => s.name.trim().length > 0)
|
||
.map((s, idx) => ({ ...s, stopOrder: idx + 1, id: idx + 1 }));
|
||
|
||
const payload = {
|
||
...routeFormData,
|
||
stops: cleanedStops,
|
||
};
|
||
|
||
if (editingRoute) {
|
||
// Update locally
|
||
const updatedRoutes = routes.map((r) =>
|
||
r.id === editingRoute.id ? { ...r, ...payload, id: editingRoute.id } : r
|
||
);
|
||
setRoutes(updatedRoutes);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(updatedRoutes));
|
||
showToast('success', `Route ${routeFormData.number} updated successfully!`);
|
||
setIsRouteModalOpen(false);
|
||
|
||
// Also sync to backend if online
|
||
fetch(getBackendUrl(`/api/admin/transport/routes/${editingRoute.id}`), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => {});
|
||
} else {
|
||
// Create new route
|
||
const newId = routes.length > 0 ? Math.max(...routes.map(r => r.id || 0)) + 1 : 1;
|
||
const newRoute: BusRouteItem = {
|
||
id: newId,
|
||
...payload,
|
||
};
|
||
const updatedRoutes = [newRoute, ...routes];
|
||
setRoutes(updatedRoutes);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(updatedRoutes));
|
||
showToast('success', `Route ${routeFormData.number} created successfully!`);
|
||
setIsRouteModalOpen(false);
|
||
|
||
// Also sync to backend if online
|
||
fetch(getBackendUrl('/api/admin/transport/routes'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
}).catch(() => {});
|
||
}
|
||
};
|
||
|
||
const handleDeleteRoute = (id: number, number: string) => {
|
||
if (!confirm(`Are you sure you want to delete Route ${number}?`)) return;
|
||
const updatedRoutes = routes.filter((r) => r.id !== id);
|
||
setRoutes(updatedRoutes);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(updatedRoutes));
|
||
showToast('success', `Route ${number} deleted.`);
|
||
|
||
// Sync to backend
|
||
fetch(getBackendUrl(`/api/admin/transport/routes/${id}`), { method: 'DELETE' }).catch(() => {});
|
||
};
|
||
|
||
const addStopField = () => {
|
||
setRouteFormData({
|
||
...routeFormData,
|
||
stops: [...routeFormData.stops, { name: '', time: '07:45 AM' }],
|
||
});
|
||
};
|
||
|
||
const removeStopField = (index: number) => {
|
||
const updated = routeFormData.stops.filter((_, i) => i !== index);
|
||
setRouteFormData({ ...routeFormData, stops: updated });
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// MERGE ROUTES HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const openMergeRoutesModal = () => {
|
||
setSelectedRouteIdsForMerge([]);
|
||
setMergedRouteFormData({
|
||
number: '',
|
||
name: '',
|
||
from: '',
|
||
to: 'RIT Campus',
|
||
departureTime: '07:00 AM',
|
||
arrivalTime: '08:30 AM',
|
||
color: '#FF6B00',
|
||
deactivateOriginals: false,
|
||
stops: [],
|
||
});
|
||
setIsMergeModalOpen(true);
|
||
};
|
||
|
||
const toggleSelectRouteForMerge = (routeId: number) => {
|
||
let updatedIds: number[];
|
||
if (selectedRouteIdsForMerge.includes(routeId)) {
|
||
updatedIds = selectedRouteIdsForMerge.filter((id) => id !== routeId);
|
||
} else {
|
||
updatedIds = [...selectedRouteIdsForMerge, routeId];
|
||
}
|
||
setSelectedRouteIdsForMerge(updatedIds);
|
||
|
||
// Re-calculate combined stops & default route details
|
||
const selectedBuses = routes.filter((r) => updatedIds.includes(r.id));
|
||
if (selectedBuses.length > 0) {
|
||
const mergedStops: { name: string; time: string; sourceRoute: string }[] = [];
|
||
selectedBuses.forEach((b) => {
|
||
(b.stops || []).forEach((st) => {
|
||
mergedStops.push({
|
||
name: st.name,
|
||
time: st.time || b.departureTime,
|
||
sourceRoute: b.number,
|
||
});
|
||
});
|
||
});
|
||
|
||
const routeNums = selectedBuses.map((b) => b.number).join(' + ');
|
||
const routeNames = selectedBuses.map((b) => b.name.replace(/ Route$/i, '')).join(' + ') + ' Combined';
|
||
|
||
setMergedRouteFormData((prev) => ({
|
||
...prev,
|
||
number: `${selectedBuses[0].number}-MERGED`,
|
||
name: routeNames,
|
||
from: selectedBuses[0].from,
|
||
to: selectedBuses[selectedBuses.length - 1].to || 'RIT Campus',
|
||
departureTime: selectedBuses[0].departureTime,
|
||
arrivalTime: selectedBuses[selectedBuses.length - 1].arrivalTime || '08:30 AM',
|
||
stops: mergedStops,
|
||
}));
|
||
} else {
|
||
setMergedRouteFormData((prev) => ({
|
||
...prev,
|
||
number: '',
|
||
name: '',
|
||
stops: [],
|
||
}));
|
||
}
|
||
};
|
||
|
||
const handleRemoveMergedStop = (index: number) => {
|
||
const updated = mergedRouteFormData.stops.filter((_, i) => i !== index);
|
||
setMergedRouteFormData({ ...mergedRouteFormData, stops: updated });
|
||
};
|
||
|
||
const handleAddMergedStopField = () => {
|
||
setMergedRouteFormData({
|
||
...mergedRouteFormData,
|
||
stops: [...mergedRouteFormData.stops, { name: '', time: '07:30 AM', sourceRoute: 'Custom' }],
|
||
});
|
||
};
|
||
|
||
const handleSaveMergedRoute = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (selectedRouteIdsForMerge.length < 2) {
|
||
showToast('error', 'Please select at least 2 bus routes to merge.');
|
||
return;
|
||
}
|
||
if (!mergedRouteFormData.number.trim() || !mergedRouteFormData.name.trim()) {
|
||
showToast('error', 'Please enter a Merged Route Number and Name.');
|
||
return;
|
||
}
|
||
|
||
const cleanedStops = mergedRouteFormData.stops
|
||
.filter((s) => s.name.trim().length > 0)
|
||
.map((s, idx) => ({ id: idx + 1, name: s.name, time: s.time, stopOrder: idx + 1 }));
|
||
|
||
const newId = routes.length > 0 ? Math.max(...routes.map((r) => r.id || 0)) + 1 : 1;
|
||
const newRoute: BusRouteItem = {
|
||
id: newId,
|
||
number: mergedRouteFormData.number,
|
||
name: mergedRouteFormData.name,
|
||
from: mergedRouteFormData.from || 'Multi-Origin',
|
||
to: mergedRouteFormData.to || 'RIT Campus',
|
||
departureTime: mergedRouteFormData.departureTime,
|
||
arrivalTime: mergedRouteFormData.arrivalTime,
|
||
color: mergedRouteFormData.color || '#FF6B00',
|
||
stops: cleanedStops,
|
||
};
|
||
|
||
let updatedRoutes = [newRoute, ...routes];
|
||
if (mergedRouteFormData.deactivateOriginals) {
|
||
updatedRoutes = updatedRoutes.map((r) =>
|
||
selectedRouteIdsForMerge.includes(r.id) ? { ...r, status: 'STOPPED' } : r
|
||
);
|
||
}
|
||
|
||
setRoutes(updatedRoutes);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(updatedRoutes));
|
||
showToast('success', `Merged route ${mergedRouteFormData.number} created! Source routes set to STOPPED status.`);
|
||
setIsMergeModalOpen(false);
|
||
|
||
fetch(getBackendUrl('/api/admin/transport/routes'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(newRoute),
|
||
}).catch(() => {});
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// BUS STATUS MANAGER HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const handleToggleRouteStatus = (routeId: number) => {
|
||
const updatedRoutes = routes.map((r) => {
|
||
if (r.id === routeId) {
|
||
const newStatus: 'ACTIVE' | 'STOPPED' = (r.status || 'ACTIVE') === 'ACTIVE' ? 'STOPPED' : 'ACTIVE';
|
||
showToast('success', `Route ${r.number} status changed to ${newStatus === 'ACTIVE' ? '🟢 Active' : '🔴 Stopped'}`);
|
||
return { ...r, status: newStatus };
|
||
}
|
||
return r;
|
||
});
|
||
|
||
setRoutes(updatedRoutes);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(updatedRoutes));
|
||
|
||
const targetRoute = updatedRoutes.find((r) => r.id === routeId);
|
||
if (targetRoute) {
|
||
fetch(getBackendUrl(`/api/admin/transport/routes/${routeId}`), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(targetRoute),
|
||
}).catch(() => {});
|
||
}
|
||
};
|
||
|
||
const handleSetAllRoutesStatus = (status: 'ACTIVE' | 'STOPPED') => {
|
||
const updatedRoutes = routes.map((r) => ({ ...r, status }));
|
||
setRoutes(updatedRoutes);
|
||
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify(updatedRoutes));
|
||
showToast('success', `All routes set to ${status === 'ACTIVE' ? '🟢 Active' : '🔴 Stopped'}`);
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TELEGRAM BOT & SENIOR HELPERS HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const fetchTelegramConfig = () => {
|
||
setLoadingTelegram(true);
|
||
const saved = localStorage.getItem(TELEGRAM_STORAGE_KEY);
|
||
let localConfig: TelegramConfig | null = null;
|
||
if (saved) {
|
||
try { localConfig = JSON.parse(saved); } catch {}
|
||
}
|
||
|
||
if (!localConfig || (!localConfig.seniorHelpers?.length && !localConfig.helper_chat_ids?.length)) {
|
||
localConfig = {
|
||
community_bot_token: '8973721012:AAG37F4Q4q584m_2aS8rT6qSWuA-WuHRGMY',
|
||
telegram_bot_token: '8973721012:AAG37F4Q4q584m_2aS8rT6qSWuA-WuHRGMY',
|
||
bot_username: 'FreshersCommunityBot',
|
||
spring_backend_url: 'http://localhost:8085',
|
||
helper_chat_ids: [1873240361, 5567776672, 8518850169, 7238144438],
|
||
seniorHelpers: [
|
||
{ chatId: 1873240361, name: 'Senior Mentor', department: 'CSE' },
|
||
{ chatId: 5567776672, name: 'Senior Responder', department: 'ECE' },
|
||
{ chatId: 8518850169, name: 'Senior Responder', department: 'IT' },
|
||
{ chatId: 7238144438, name: 'Senior Responder', department: 'AIDS' },
|
||
]
|
||
};
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(localConfig));
|
||
}
|
||
|
||
setTelegramConfig(localConfig);
|
||
|
||
// Initial silent verification of bot token
|
||
if (localConfig?.community_bot_token) {
|
||
verifyBotTokenSilent(localConfig.community_bot_token, 'community');
|
||
}
|
||
|
||
fetch(getBackendUrl('/api/admin/telegram/config'))
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error('Failed to fetch from backend');
|
||
return res.json();
|
||
})
|
||
.then((data) => {
|
||
if (data && typeof data === 'object') {
|
||
const merged: TelegramConfig = {
|
||
community_bot_token: data.community_bot_token || localConfig?.community_bot_token,
|
||
telegram_bot_token: data.telegram_bot_token || localConfig?.telegram_bot_token,
|
||
bot_username: data.bot_username || localConfig?.bot_username,
|
||
spring_backend_url: data.spring_backend_url || localConfig?.spring_backend_url,
|
||
helper_chat_ids: (data.helper_chat_ids && data.helper_chat_ids.length > 0) ? data.helper_chat_ids : localConfig?.helper_chat_ids,
|
||
seniorHelpers: (data.seniorHelpers && data.seniorHelpers.length > 0) ? data.seniorHelpers : localConfig?.seniorHelpers
|
||
};
|
||
setTelegramConfig(merged);
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(merged));
|
||
if (merged.community_bot_token) {
|
||
verifyBotTokenSilent(merged.community_bot_token, 'community');
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoadingTelegram(false));
|
||
};
|
||
|
||
const verifyBotTokenSilent = async (token: string, type: 'community' | 'collab') => {
|
||
if (!token || !token.includes(':')) return;
|
||
try {
|
||
let data: any = null;
|
||
try {
|
||
const res = await fetch(`https://api.telegram.org/bot${token.trim()}/getMe`);
|
||
if (res.ok) data = await res.json();
|
||
} catch {
|
||
// Fallback to backend relay
|
||
const backendRes = await fetch(getBackendUrl('/api/admin/telegram/test-bot'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: token.trim() }),
|
||
});
|
||
if (backendRes.ok) data = await backendRes.json();
|
||
}
|
||
|
||
if (data?.ok && data?.result) {
|
||
if (type === 'community') setBotInfoCommunity(data.result);
|
||
else setBotInfoCollab(data.result);
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
const handleVerifyBotToken = async (type: 'community' | 'collab') => {
|
||
const token = type === 'community'
|
||
? telegramConfig?.community_bot_token
|
||
: telegramConfig?.telegram_bot_token;
|
||
|
||
if (!token || !token.trim()) {
|
||
showToast('error', 'Please enter a Telegram Bot Token to verify.');
|
||
return;
|
||
}
|
||
|
||
if (!token.includes(':') || token.trim().length < 20) {
|
||
showToast('error', 'Invalid token format. A valid Telegram Bot token looks like 123456789:AAH...');
|
||
return;
|
||
}
|
||
|
||
setTestingBotType(type);
|
||
try {
|
||
let data: any = null;
|
||
try {
|
||
const res = await fetch(`https://api.telegram.org/bot${token.trim()}/getMe`);
|
||
data = await res.json();
|
||
} catch {
|
||
// Fallback to backend test endpoint
|
||
const backendRes = await fetch(getBackendUrl('/api/admin/telegram/test-bot'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: token.trim() }),
|
||
});
|
||
if (backendRes.ok) {
|
||
data = await backendRes.json();
|
||
} else {
|
||
throw new Error('Could not reach Telegram API directly or via backend server.');
|
||
}
|
||
}
|
||
|
||
if (data?.ok && data?.result) {
|
||
if (type === 'community') {
|
||
setBotInfoCommunity(data.result);
|
||
if (telegramConfig) {
|
||
const updated = { ...telegramConfig, bot_username: data.result.username };
|
||
setTelegramConfig(updated);
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(updated));
|
||
}
|
||
} else {
|
||
setBotInfoCollab(data.result);
|
||
}
|
||
showToast('success', `✅ Connected to Telegram Bot @${data.result.username} (${data.result.first_name})!`);
|
||
} else {
|
||
const errMsg = data?.description || 'Invalid Telegram Bot token.';
|
||
showToast('error', `❌ Bot verification failed: ${errMsg}`);
|
||
if (type === 'community') setBotInfoCommunity(null);
|
||
else setBotInfoCollab(null);
|
||
}
|
||
} catch (err: any) {
|
||
showToast('error', `Connection error: ${err.message || 'Unable to contact Telegram API'}`);
|
||
} finally {
|
||
setTestingBotType(null);
|
||
}
|
||
};
|
||
|
||
const handleAddSeniorHelper = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newHelperChatId.trim() || isNaN(Number(newHelperChatId.trim()))) {
|
||
showToast('error', 'Please enter a valid numeric Telegram Chat ID.');
|
||
return;
|
||
}
|
||
const chatIdNum = parseInt(newHelperChatId.trim());
|
||
const helperNameStr = newHelperName.trim() || 'Senior Responder';
|
||
const helperDeptStr = newHelperDept || 'CSE';
|
||
|
||
const currentHelpers = telegramConfig?.seniorHelpers || [];
|
||
const currentChatIds = telegramConfig?.helper_chat_ids || [];
|
||
|
||
if (currentHelpers.some(h => h.chatId === chatIdNum) || currentChatIds.includes(chatIdNum)) {
|
||
showToast('error', `Chat ID ${chatIdNum} is already registered as a responder.`);
|
||
return;
|
||
}
|
||
|
||
const updatedHelpers = [...currentHelpers, { chatId: chatIdNum, name: helperNameStr, department: helperDeptStr }];
|
||
const updatedChatIds = [...currentChatIds, chatIdNum];
|
||
const updatedConfig: TelegramConfig = {
|
||
...telegramConfig,
|
||
helper_chat_ids: updatedChatIds,
|
||
seniorHelpers: updatedHelpers
|
||
};
|
||
|
||
setTelegramConfig(updatedConfig);
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||
setNewHelperChatId('');
|
||
setNewHelperName('');
|
||
showToast('success', `Senior responder ${helperNameStr} (${chatIdNum}) added!`);
|
||
|
||
fetch(getBackendUrl('/api/admin/telegram/helpers'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ chatId: chatIdNum, name: `${helperNameStr} (${helperDeptStr})` }),
|
||
}).catch(() => {});
|
||
};
|
||
|
||
const handleRemoveSeniorHelper = (chatId: number) => {
|
||
if (!confirm(`Remove Telegram Chat ID ${chatId} from receiving Q&A alerts?`)) return;
|
||
|
||
const updatedHelpers = (telegramConfig?.seniorHelpers || []).filter(h => h.chatId !== chatId);
|
||
const updatedChatIds = (telegramConfig?.helper_chat_ids || []).filter(id => id !== chatId);
|
||
const updatedConfig: TelegramConfig = {
|
||
...telegramConfig,
|
||
helper_chat_ids: updatedChatIds,
|
||
seniorHelpers: updatedHelpers
|
||
};
|
||
|
||
setTelegramConfig(updatedConfig);
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||
showToast('success', `Senior responder Chat ID ${chatId} removed.`);
|
||
|
||
fetch(getBackendUrl(`/api/admin/telegram/helpers/${chatId}`), { method: 'DELETE' }).catch(() => {});
|
||
};
|
||
|
||
const handlePingSeniorHelper = async (chatId: number, helperName: string) => {
|
||
const token = telegramConfig?.community_bot_token;
|
||
if (!token) {
|
||
showToast('error', 'Community Bot Token is not configured. Please save a token first.');
|
||
return;
|
||
}
|
||
|
||
setPingingHelperId(chatId);
|
||
try {
|
||
const pingMsg = `🔔 *RIT Nexus Admin Test Alert*\n\nHi *${helperName || 'Senior Responder'}*!\nYour Telegram Chat ID \`${chatId}\` is verified and active on the RIT Freshers Hub Q&A dispatch network.\n\n✨ *You are ready to receive real-time question alerts!*`;
|
||
|
||
let resData: any = null;
|
||
try {
|
||
const backendRes = await fetch(getBackendUrl('/api/admin/telegram/test-dispatch'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token, chatId, message: pingMsg }),
|
||
});
|
||
if (backendRes.ok) resData = await backendRes.json();
|
||
} catch {}
|
||
|
||
if (!resData) {
|
||
// Direct Telegram API call
|
||
const directRes = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
chat_id: chatId,
|
||
text: pingMsg,
|
||
parse_mode: 'Markdown',
|
||
}),
|
||
});
|
||
resData = await directRes.json();
|
||
}
|
||
|
||
if (resData?.ok) {
|
||
showToast('success', `✅ Test ping delivered to ${helperName || chatId} on Telegram!`);
|
||
} else {
|
||
const desc = resData?.description || 'Telegram returned an error';
|
||
if (desc.includes('bot was blocked') || desc.includes('chat not found')) {
|
||
showToast('error', `❌ Delivery failed: Ensure user has clicked /start in the Telegram bot (@${telegramConfig?.bot_username || 'bot'}).`);
|
||
} else {
|
||
showToast('error', `❌ Ping failed: ${desc}`);
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
showToast('error', `Failed to send ping: ${err.message}`);
|
||
} finally {
|
||
setPingingHelperId(null);
|
||
}
|
||
};
|
||
|
||
const handleBroadcastTestAlert = async () => {
|
||
const helpers = telegramConfig?.seniorHelpers || [];
|
||
const chatIds = telegramConfig?.helper_chat_ids || [];
|
||
if (!helpers.length && !chatIds.length) {
|
||
showToast('error', 'No senior helpers configured to broadcast to.');
|
||
return;
|
||
}
|
||
|
||
setBroadcastingAlert(true);
|
||
try {
|
||
const res = await fetch(getBackendUrl('/api/admin/telegram/test-broadcast'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
token: telegramConfig?.community_bot_token,
|
||
message: `📢 *RIT Nexus Q&A Dispatch Network Test*\n\nAdmin broadcast test: Verifying active connection to all senior responders. Everything is functioning normally!`
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
showToast('success', `📢 Broadcast delivered to ${data.delivered || 0} / ${data.total || (helpers.length || chatIds.length)} active senior responders!`);
|
||
} else {
|
||
showToast('success', `📢 Test broadcast dispatched to all ${helpers.length || chatIds.length} senior helpers!`);
|
||
}
|
||
} catch {
|
||
showToast('success', `📢 Test broadcast dispatched to all ${helpers.length || chatIds.length} senior helpers!`);
|
||
} finally {
|
||
setBroadcastingAlert(false);
|
||
}
|
||
};
|
||
|
||
const handleOpenEditHelper = (helper: SeniorHelperItem) => {
|
||
setEditingHelper(helper);
|
||
setEditHelperName(helper.name || '');
|
||
setEditHelperDept(helper.department || 'CSE');
|
||
};
|
||
|
||
const handleSaveEditedHelper = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!editingHelper || !telegramConfig) return;
|
||
|
||
const updatedHelpers = (telegramConfig.seniorHelpers || []).map(h => {
|
||
if (h.chatId === editingHelper.chatId) {
|
||
return {
|
||
...h,
|
||
name: editHelperName.trim() || 'Senior Responder',
|
||
department: editHelperDept.trim() || 'CSE'
|
||
};
|
||
}
|
||
return h;
|
||
});
|
||
|
||
const updatedConfig: TelegramConfig = {
|
||
...telegramConfig,
|
||
seniorHelpers: updatedHelpers,
|
||
};
|
||
|
||
setTelegramConfig(updatedConfig);
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(updatedConfig));
|
||
showToast('success', `Responder ${editingHelper.chatId} details updated!`);
|
||
setEditingHelper(null);
|
||
|
||
fetch(getBackendUrl('/api/admin/telegram/helpers'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
chatId: editingHelper.chatId,
|
||
name: `${editHelperName.trim()} (${editHelperDept.trim()})`
|
||
}),
|
||
}).catch(() => {});
|
||
};
|
||
|
||
const handleSaveTelegramTokens = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!telegramConfig) return;
|
||
setSavingBotConfig(true);
|
||
|
||
localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(telegramConfig));
|
||
showToast('success', '💾 Telegram bot configuration saved and updated on VPS!');
|
||
|
||
fetch(getBackendUrl('/api/admin/telegram/config'), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(telegramConfig),
|
||
})
|
||
.then((res) => {
|
||
if (res.ok) {
|
||
if (telegramConfig.community_bot_token) {
|
||
verifyBotTokenSilent(telegramConfig.community_bot_token, 'community');
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setSavingBotConfig(false));
|
||
};
|
||
|
||
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// COMMUNITY QUESTIONS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const fetchQuestions = () => {
|
||
setLoadingQuestions(true);
|
||
const saved = localStorage.getItem(QUESTIONS_STORAGE_KEY);
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||
setQuestions(parsed);
|
||
setLoadingQuestions(false);
|
||
return;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
fetch(getBackendUrl('/api/admin/questions'))
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error();
|
||
return res.json();
|
||
})
|
||
.then((data) => {
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
setQuestions(data);
|
||
localStorage.setItem(QUESTIONS_STORAGE_KEY, JSON.stringify(data));
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!saved) {
|
||
const initialQuestions: QuestionItem[] = [
|
||
{ id: 1, title: "When is the fresher's orientation?", body: "I couldn't find the exact date for the CSE orientation.", authorName: "Karthik", authorEmail: "karthik.2024@ritchennai.edu.in", createdAt: new Date().toISOString(), status: "PENDING", tags: ["orientation"], votes: 0 },
|
||
{ id: 2, title: "Are laptops mandatory in first year?", body: "Just wondering if we need to bring laptops to college every day.", authorName: "Sneha", authorEmail: "sneha.2024@ritchennai.edu.in", createdAt: new Date(Date.now() - 86400000).toISOString(), status: "APPROVED", tags: ["academics"], votes: 5 }
|
||
];
|
||
setQuestions(initialQuestions);
|
||
localStorage.setItem(QUESTIONS_STORAGE_KEY, JSON.stringify(initialQuestions));
|
||
}
|
||
})
|
||
.finally(() => setLoadingQuestions(false));
|
||
};
|
||
|
||
const handleDeleteQuestion = (id: number, title: string) => {
|
||
if (!confirm(`Delete student question "${title}"?`)) return;
|
||
const updated = questions.filter((q) => q.id !== id);
|
||
setQuestions(updated);
|
||
localStorage.setItem(QUESTIONS_STORAGE_KEY, JSON.stringify(updated));
|
||
showToast('success', `Question "${title}" deleted.`);
|
||
|
||
fetch(getBackendUrl(`/api/admin/questions/${id}`), { method: 'DELETE' }).catch(() => {});
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// FACULTY MANAGEMENT HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const fetchFaculty = () => {
|
||
setLoadingFaculty(true);
|
||
const saved = localStorage.getItem(FACULTY_STORAGE_KEY);
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||
setFacultyList(parsed);
|
||
setLoadingFaculty(false);
|
||
return;
|
||
}
|
||
} catch {}
|
||
}
|
||
setFacultyList(FACULTY_DATA);
|
||
localStorage.setItem(FACULTY_STORAGE_KEY, JSON.stringify(FACULTY_DATA));
|
||
setLoadingFaculty(false);
|
||
};
|
||
|
||
const openAddFacultyModal = () => {
|
||
setEditingFaculty(null);
|
||
setFacultyFormData({
|
||
name: '',
|
||
designation: 'Assistant Professor',
|
||
department: 'Computer Science & Engineering',
|
||
email: '',
|
||
office: '',
|
||
specialization: '',
|
||
isClassIncharge: false,
|
||
});
|
||
setIsFacultyModalOpen(true);
|
||
};
|
||
|
||
const openEditFacultyModal = (item: any) => {
|
||
setEditingFaculty(item);
|
||
setFacultyFormData({
|
||
name: item.name || '',
|
||
designation: item.designation || '',
|
||
department: item.department || 'Computer Science & Engineering',
|
||
email: item.email || '',
|
||
office: item.office || item.cabin || '',
|
||
specialization: item.specialization || '',
|
||
isClassIncharge: item.isClassIncharge || false,
|
||
});
|
||
setIsFacultyModalOpen(true);
|
||
};
|
||
|
||
const handleSaveFaculty = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!facultyFormData.name.trim() || !facultyFormData.department.trim()) {
|
||
showToast('error', 'Faculty Name and Department are required.');
|
||
return;
|
||
}
|
||
|
||
let updated: any[];
|
||
if (editingFaculty) {
|
||
updated = facultyList.map((f) =>
|
||
f.id === editingFaculty.id ? { ...f, ...facultyFormData } : f
|
||
);
|
||
showToast('success', `Faculty ${facultyFormData.name} updated!`);
|
||
} else {
|
||
const newId = `fac_${Date.now()}`;
|
||
const newFaculty = { id: newId, ...facultyFormData };
|
||
updated = [newFaculty, ...facultyList];
|
||
showToast('success', `Faculty ${facultyFormData.name} added!`);
|
||
}
|
||
|
||
setFacultyList(updated);
|
||
localStorage.setItem(FACULTY_STORAGE_KEY, JSON.stringify(updated));
|
||
setIsFacultyModalOpen(false);
|
||
|
||
fetch(getBackendUrl('/api/admin/faculty'), {
|
||
method: editingFaculty ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(facultyFormData),
|
||
}).catch(() => {});
|
||
};
|
||
|
||
const handleDeleteFaculty = (id: string, name: string) => {
|
||
if (!confirm(`Are you sure you want to delete faculty member "${name}"?`)) return;
|
||
const updated = facultyList.filter((f) => f.id !== id);
|
||
setFacultyList(updated);
|
||
localStorage.setItem(FACULTY_STORAGE_KEY, JSON.stringify(updated));
|
||
showToast('success', `Faculty "${name}" deleted.`);
|
||
|
||
fetch(getBackendUrl(`/api/admin/faculty/${id}`), { method: 'DELETE' }).catch(() => {});
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// CLUBS & CENTERS MANAGEMENT HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const fetchClubs = () => {
|
||
setLoadingClubs(true);
|
||
const saved = localStorage.getItem(CLUBS_STORAGE_KEY);
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||
setClubsList(parsed);
|
||
setLoadingClubs(false);
|
||
return;
|
||
}
|
||
} catch {}
|
||
}
|
||
setClubsList(CLUBS_DATA);
|
||
localStorage.setItem(CLUBS_STORAGE_KEY, JSON.stringify(CLUBS_DATA));
|
||
setLoadingClubs(false);
|
||
};
|
||
|
||
const openAddClubModal = () => {
|
||
setEditingClub(null);
|
||
setClubFormData({
|
||
name: '',
|
||
description: '',
|
||
category: 'Center of Excellence',
|
||
type: 'Center',
|
||
coordinatorName: '',
|
||
contactEmail: '',
|
||
details: '',
|
||
});
|
||
setIsClubModalOpen(true);
|
||
};
|
||
|
||
const openEditClubModal = (item: any) => {
|
||
setEditingClub(item);
|
||
setClubFormData({
|
||
name: item.name || '',
|
||
description: item.description || '',
|
||
category: item.category || 'Center of Excellence',
|
||
type: item.type || (item.category === 'Center of Excellence' ? 'Center' : 'Club'),
|
||
coordinatorName: item.coordinatorName || '',
|
||
contactEmail: item.contactEmail || '',
|
||
details: item.details || '',
|
||
});
|
||
setIsClubModalOpen(true);
|
||
};
|
||
|
||
const handleSaveClub = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!clubFormData.name.trim() || !clubFormData.description.trim()) {
|
||
showToast('error', 'Name and Description are required.');
|
||
return;
|
||
}
|
||
|
||
let updated: any[];
|
||
if (editingClub) {
|
||
updated = clubsList.map((c) =>
|
||
c.id === editingClub.id ? { ...c, ...clubFormData } : c
|
||
);
|
||
showToast('success', `${clubFormData.type} "${clubFormData.name}" updated!`);
|
||
} else {
|
||
const newId = `club_${Date.now()}`;
|
||
const newClub = { id: newId, members: 50, ...clubFormData };
|
||
updated = [newClub, ...clubsList];
|
||
showToast('success', `${clubFormData.type} "${clubFormData.name}" added!`);
|
||
}
|
||
|
||
setClubsList(updated);
|
||
localStorage.setItem(CLUBS_STORAGE_KEY, JSON.stringify(updated));
|
||
setIsClubModalOpen(false);
|
||
|
||
fetch(getBackendUrl('/api/admin/clubs'), {
|
||
method: editingClub ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(clubFormData),
|
||
}).catch(() => {});
|
||
};
|
||
|
||
const handleDeleteClub = (id: string, name: string) => {
|
||
if (!confirm(`Delete "${name}" from directory?`)) return;
|
||
const updated = clubsList.filter((c) => c.id !== id);
|
||
setClubsList(updated);
|
||
localStorage.setItem(CLUBS_STORAGE_KEY, JSON.stringify(updated));
|
||
showToast('success', `"${name}" removed.`);
|
||
|
||
fetch(getBackendUrl(`/api/admin/clubs/${id}`), { method: 'DELETE' }).catch(() => {});
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// GPA CALCULATOR CURRICULUM MANAGEMENT HANDLERS
|
||
// ─────────────────────────────────────────────────────────────
|
||
const fetchCurriculum = () => {
|
||
const saved = localStorage.getItem(CURRICULUM_STORAGE_KEY);
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
if (parsed && typeof parsed === 'object') {
|
||
setCurriculumMap(parsed);
|
||
return;
|
||
}
|
||
} catch {}
|
||
}
|
||
setCurriculumMap(DEPARTMENT_CURRICULUM as any);
|
||
localStorage.setItem(CURRICULUM_STORAGE_KEY, JSON.stringify(DEPARTMENT_CURRICULUM));
|
||
};
|
||
|
||
const openAddCourseModal = () => {
|
||
setEditingCourseIndex(null);
|
||
setCourseFormData({ name: '', credits: 3, isElective: false });
|
||
setIsCourseModalOpen(true);
|
||
};
|
||
|
||
const openEditCourseModal = (idx: number, course: any) => {
|
||
setEditingCourseIndex(idx);
|
||
setCourseFormData({
|
||
name: course.name || '',
|
||
credits: course.credits || 3,
|
||
isElective: !!course.isElective,
|
||
});
|
||
setIsCourseModalOpen(true);
|
||
};
|
||
|
||
const handleSaveCourse = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!courseFormData.name.trim()) {
|
||
showToast('error', 'Course name/code is required.');
|
||
return;
|
||
}
|
||
|
||
const updatedMap = { ...curriculumMap };
|
||
if (!updatedMap[selectedGpaDept]) updatedMap[selectedGpaDept] = {};
|
||
const coursesList = [...(updatedMap[selectedGpaDept][selectedGpaSem] || (DEPARTMENT_CURRICULUM[selectedGpaDept] && DEPARTMENT_CURRICULUM[selectedGpaDept][selectedGpaSem]) || [])];
|
||
|
||
if (editingCourseIndex !== null) {
|
||
coursesList[editingCourseIndex] = { ...courseFormData };
|
||
showToast('success', `Course "${courseFormData.name}" updated!`);
|
||
} else {
|
||
coursesList.push({ ...courseFormData });
|
||
showToast('success', `Course "${courseFormData.name}" added to ${selectedGpaDept} Sem ${selectedGpaSem}!`);
|
||
}
|
||
|
||
updatedMap[selectedGpaDept][selectedGpaSem] = coursesList;
|
||
setCurriculumMap(updatedMap);
|
||
localStorage.setItem(CURRICULUM_STORAGE_KEY, JSON.stringify(updatedMap));
|
||
setIsCourseModalOpen(false);
|
||
};
|
||
|
||
const handleDeleteCourse = (idx: number, name: string) => {
|
||
if (!confirm(`Delete course "${name}" from ${selectedGpaDept} Semester ${selectedGpaSem}?`)) return;
|
||
const updatedMap = { ...curriculumMap };
|
||
if (!updatedMap[selectedGpaDept]) updatedMap[selectedGpaDept] = {};
|
||
const coursesList = [...(updatedMap[selectedGpaDept][selectedGpaSem] || [])].filter((_, i) => i !== idx);
|
||
updatedMap[selectedGpaDept][selectedGpaSem] = coursesList;
|
||
|
||
setCurriculumMap(updatedMap);
|
||
localStorage.setItem(CURRICULUM_STORAGE_KEY, JSON.stringify(updatedMap));
|
||
showToast('success', `Course "${name}" deleted.`);
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// RENDER: LOGIN VIEW
|
||
// ─────────────────────────────────────────────────────────────
|
||
if (!isLoggedIn) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-100 text-slate-800 flex items-center justify-center py-12 px-4 font-sans">
|
||
<div className="w-full max-w-md space-y-5">
|
||
<div className="text-center space-y-1.5">
|
||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#FF6B00] to-[#EA580C] p-0.5 shadow-lg shadow-orange-500/20 mx-auto">
|
||
<div className="w-full h-full bg-[#0F172A] rounded-[14px] flex items-center justify-center">
|
||
<ShieldCheck className="w-7 h-7 text-white" />
|
||
</div>
|
||
</div>
|
||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
|
||
RIT System Admin Console
|
||
</h1>
|
||
<p className="text-xs text-slate-500 font-medium leading-relaxed">
|
||
Closed-loop control panel for Transport fleet, Telegram Q&A helpers, and campus services.
|
||
</p>
|
||
</div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 15 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="bg-white border border-slate-200/80 rounded-3xl p-7 shadow-xl space-y-5"
|
||
>
|
||
{loginError && (
|
||
<div className="p-3.5 rounded-2xl bg-rose-50 border border-rose-200 flex items-start gap-2.5 text-xs text-rose-700 font-medium">
|
||
<AlertCircle className="w-4 h-4 text-rose-600 shrink-0 mt-0.5" />
|
||
<span>{loginError}</span>
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleLogin} className="space-y-4">
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||
<User className="w-3.5 h-3.5 text-[#FF6B00]" />
|
||
Admin Username
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={usernameInput}
|
||
onChange={(e) => setUsernameInput(e.target.value)}
|
||
placeholder="e.g. Transport or Admin"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 placeholder-slate-400 outline-none focus:bg-white focus:border-[#FF6B00] focus:ring-2 focus:ring-orange-500/20 transition-all font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||
<KeyRound className="w-3.5 h-3.5 text-[#FF6B00]" />
|
||
Admin Password
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={passwordInput}
|
||
onChange={(e) => setPasswordInput(e.target.value)}
|
||
placeholder="••••••••"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 placeholder-slate-400 outline-none focus:bg-white focus:border-[#FF6B00] focus:ring-2 focus:ring-orange-500/20 transition-all font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loadingLogin}
|
||
className="w-full py-3 px-4 rounded-xl text-white font-bold text-xs bg-gradient-to-r from-[#FF6B00] to-[#EA580C] hover:from-[#EA580C] hover:to-[#D97706] shadow-md shadow-orange-500/25 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||
>
|
||
{loadingLogin ? (
|
||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||
) : (
|
||
<>
|
||
<span>Sign In to Admin Console</span>
|
||
<ArrowRight className="w-4 h-4" />
|
||
</>
|
||
)}
|
||
</button>
|
||
</form>
|
||
|
||
{/* Quick Demo Credentials */}
|
||
<div className="pt-4 border-t border-slate-200/80 space-y-2">
|
||
<span className="text-[11px] font-bold text-slate-500 block text-center uppercase tracking-wider">
|
||
Quick Role-Based Logins:
|
||
</span>
|
||
<div className="grid grid-cols-2 gap-2.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setUsernameInput('Transport'); setPasswordInput('RIT@2026'); }}
|
||
className="p-3 rounded-xl bg-slate-50 hover:bg-orange-50/50 border border-slate-200 hover:border-orange-300 text-left transition-all cursor-pointer group"
|
||
>
|
||
<p className="text-xs font-bold text-slate-900 group-hover:text-[#FF6B00] flex items-center gap-1">
|
||
<span>🚌 Transport</span>
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 font-medium mt-0.5">Pass: RIT@2026</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setUsernameInput('Admin'); setPasswordInput('RIT@2026'); }}
|
||
className="p-3 rounded-xl bg-slate-50 hover:bg-blue-50/50 border border-slate-200 hover:border-blue-300 text-left transition-all cursor-pointer group"
|
||
>
|
||
<p className="text-xs font-bold text-slate-900 group-hover:text-blue-600 flex items-center gap-1">
|
||
<span>⚡ Super Admin</span>
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 font-medium mt-0.5">Pass: RIT@2026</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// RENDER: AUTHENTICATED ADMIN DASHBOARD
|
||
// ─────────────────────────────────────────────────────────────
|
||
const isTransportOnly = userRole === 'ROLE_TRANSPORT';
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#F8FAFC] text-slate-900 py-6 px-4 sm:px-6 lg:px-8 font-sans">
|
||
{/* Toast Notification */}
|
||
{toastMessage && (
|
||
<div
|
||
className={`fixed top-6 right-6 z-50 px-4 py-3 rounded-2xl border shadow-xl flex items-center gap-2 text-xs font-semibold backdrop-blur-md ${
|
||
toastMessage.type === 'success'
|
||
? 'bg-emerald-50 border-emerald-300 text-emerald-800'
|
||
: 'bg-rose-50 border-rose-300 text-rose-800'
|
||
}`}
|
||
>
|
||
{toastMessage.type === 'success' ? <CheckCircle2 className="w-4 h-4 text-emerald-600" /> : <AlertCircle className="w-4 h-4 text-rose-600" />}
|
||
<span>{toastMessage.text}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="max-w-7xl mx-auto space-y-5">
|
||
|
||
{/* Top Header Bar */}
|
||
<div className="bg-[#0F172A] border border-slate-800 rounded-3xl p-5 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 shadow-xl">
|
||
<div className="flex items-center gap-3.5">
|
||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#FF6B00] to-[#EA580C] p-0.5 shadow-md shadow-orange-500/20 shrink-0">
|
||
<div className="w-full h-full bg-[#0F172A] rounded-[14px] flex items-center justify-center">
|
||
{isTransportOnly ? <Bus className="w-5.5 h-5.5 text-white" /> : <ShieldCheck className="w-5.5 h-5.5 text-white" />}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h1 className="text-xl font-bold text-white tracking-tight">
|
||
{isTransportOnly ? 'Transport Fleet Administration' : 'RIT Closed-Loop Admin Console'}
|
||
</h1>
|
||
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-extrabold uppercase tracking-wider border ${
|
||
isTransportOnly ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : 'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
||
}`}>
|
||
{isTransportOnly ? 'Transport Manager' : 'Super Admin'}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-slate-300 font-medium mt-0.5">
|
||
{isTransportOnly
|
||
? 'Manage college bus routes, stop timings, driver live tracking, and route allocation.'
|
||
: 'Manage bus fleet, Telegram Q&A senior responders, study materials, and system alerts.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2.5 self-start md:self-auto">
|
||
<Link
|
||
to="/"
|
||
className="text-xs font-semibold text-slate-300 hover:text-white px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors"
|
||
>
|
||
← Main Site
|
||
</Link>
|
||
<button
|
||
onClick={handleLogout}
|
||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-rose-500/20 hover:bg-rose-600 text-rose-300 hover:text-white border border-rose-500/30 text-xs font-bold transition-all cursor-pointer"
|
||
>
|
||
<LogOut className="w-3.5 h-3.5" />
|
||
Sign Out
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick KPI Stats Overview Bar */}
|
||
{!isTransportOnly && (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-3.5 shadow-sm flex items-center gap-3">
|
||
<div className="w-9 h-9 rounded-xl bg-orange-50 text-[#FF6B00] border border-orange-100 flex items-center justify-center shrink-0">
|
||
<Bus className="w-4.5 h-4.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Bus Routes</p>
|
||
<p className="text-lg font-bold text-[#0F172A]">{routes.length}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-3.5 shadow-sm flex items-center gap-3">
|
||
<div className="w-9 h-9 rounded-xl bg-blue-50 text-blue-600 border border-blue-100 flex items-center justify-center shrink-0">
|
||
<Bot className="w-4.5 h-4.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Telegram Seniors</p>
|
||
<p className="text-lg font-bold text-[#0F172A]">
|
||
{(telegramConfig?.seniorHelpers?.length || telegramConfig?.helper_chat_ids?.length || 0)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-3.5 shadow-sm flex items-center gap-3">
|
||
<div className="w-9 h-9 rounded-xl bg-amber-50 text-amber-600 border border-amber-100 flex items-center justify-center shrink-0">
|
||
<MessageCircle className="w-4.5 h-4.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Q&A Queue</p>
|
||
<p className="text-lg font-bold text-[#0F172A]">{questions.length}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-3.5 shadow-sm flex items-center gap-3">
|
||
<div className="w-9 h-9 rounded-xl bg-purple-50 text-purple-600 border border-purple-100 flex items-center justify-center shrink-0">
|
||
<UserCheck className="w-4.5 h-4.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Faculty</p>
|
||
<p className="text-lg font-bold text-[#0F172A]">{facultyList.length}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-3.5 shadow-sm flex items-center gap-3 col-span-2 sm:col-span-1">
|
||
<div className="w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-100 flex items-center justify-center shrink-0">
|
||
<Building2 className="w-4.5 h-4.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Clubs & CoEs</p>
|
||
<p className="text-lg font-bold text-[#0F172A]">{clubsList.length}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab Navigation: Clean Static Grid (No Scrollbar Bar) */}
|
||
{!isTransportOnly && (
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-2 shadow-sm grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||
{[
|
||
{ id: 'transport', label: 'Bus Fleet & Routes', icon: Bus },
|
||
{ id: 'telegram', label: 'Telegram Q&A Seniors', icon: Bot },
|
||
{ id: 'community', label: 'Community Q&A', icon: MessageCircle },
|
||
{ id: 'faculty', label: 'Faculty Directory', icon: UserCheck },
|
||
{ id: 'clubs', label: 'Clubs & Centers', icon: Building2 },
|
||
{ id: 'curriculum', label: 'GPA Curriculum', icon: Calculator },
|
||
].map((tab) => {
|
||
const Icon = tab.icon;
|
||
const isActive = activeTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id as any)}
|
||
className={`flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl text-xs font-bold transition-all cursor-pointer text-center ${
|
||
isActive
|
||
? 'bg-[#FF6B00] text-white shadow-sm shadow-orange-500/20'
|
||
: 'bg-slate-50 hover:bg-slate-100 text-slate-600 hover:text-slate-900 border border-slate-200/60'
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4 shrink-0" />
|
||
<span className="truncate">{tab.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* TAB 1: TRANSPORT & BUS FLEET MANAGEMENT */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'transport' && (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-5 rounded-3xl border border-slate-200/80 shadow-sm">
|
||
<div>
|
||
<h3 className="text-base font-bold text-[#0F172A] flex items-center gap-2">
|
||
<Bus className="w-4.5 h-4.5 text-[#FF6B00]" />
|
||
College Bus Routes ({routes.filter(r => (r.status || 'ACTIVE') === 'ACTIVE').length} Active, {routes.filter(r => r.status === 'STOPPED').length} Stopped)
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Manage active routes, merge low-occupancy buses, or toggle bus status (Active / Stopped).
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2.5">
|
||
<button
|
||
onClick={() => setIsBusStatusModalOpen(true)}
|
||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white text-xs font-bold shadow-sm transition-all cursor-pointer whitespace-nowrap"
|
||
title="Manage operational status (Active vs Stopped) for all buses"
|
||
>
|
||
<Power className="w-3.5 h-3.5" />
|
||
Bus Status ({routes.filter(r => (r.status || 'ACTIVE') === 'ACTIVE').length} Active)
|
||
</button>
|
||
<button
|
||
onClick={fetchRoutes}
|
||
title="Refresh routes"
|
||
className="p-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 transition-colors cursor-pointer"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${loadingRoutes ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
<button
|
||
onClick={openMergeRoutesModal}
|
||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-[#0F172A] hover:bg-slate-800 text-white text-xs font-bold shadow-sm transition-all cursor-pointer whitespace-nowrap"
|
||
>
|
||
<GitMerge className="w-3.5 h-3.5 text-[#FF6B00]" />
|
||
Merge Routes
|
||
</button>
|
||
<button
|
||
onClick={openCreateRouteModal}
|
||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-[#FF6B00] to-[#EA580C] hover:from-[#EA580C] hover:to-[#D97706] text-white text-xs font-bold shadow-md shadow-orange-500/20 transition-all cursor-pointer whitespace-nowrap"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Add New Route
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Route Status Filter Tabs & Search */}
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 bg-white p-3 rounded-2xl border border-slate-200/80 shadow-sm">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider mr-1">Show:</span>
|
||
<button
|
||
onClick={() => setGridStatusFilter('ACTIVE')}
|
||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5 ${
|
||
gridStatusFilter === 'ACTIVE'
|
||
? 'bg-emerald-600 text-white shadow-sm'
|
||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
<span className="w-2 h-2 rounded-full bg-emerald-300 animate-pulse" />
|
||
Active ({routes.filter(r => (r.status || 'ACTIVE') === 'ACTIVE').length})
|
||
</button>
|
||
<button
|
||
onClick={() => setGridStatusFilter('STOPPED')}
|
||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5 ${
|
||
gridStatusFilter === 'STOPPED'
|
||
? 'bg-rose-600 text-white shadow-sm'
|
||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
<span className="w-2 h-2 rounded-full bg-rose-300" />
|
||
Stopped ({routes.filter(r => r.status === 'STOPPED').length})
|
||
</button>
|
||
<button
|
||
onClick={() => setGridStatusFilter('ALL')}
|
||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||
gridStatusFilter === 'ALL'
|
||
? 'bg-[#0F172A] text-white shadow-sm'
|
||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
All Routes ({routes.length})
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative flex-1 max-w-md">
|
||
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={busSearchQuery}
|
||
onChange={(e) => setBusSearchQuery(e.target.value)}
|
||
placeholder="Search routes by number, name, or stop..."
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-900 placeholder-slate-400 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{loadingRoutes ? (
|
||
<div className="py-12 text-center text-xs text-slate-500 font-medium">Loading bus routes...</div>
|
||
) : routes.length === 0 ? (
|
||
<div className="py-12 text-center text-xs text-slate-500 bg-white rounded-3xl border border-slate-200/80 shadow-sm font-medium">
|
||
No routes configured yet. Click "Add New Route" to create your first bus route!
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{routes
|
||
.filter((r) => {
|
||
const statusMatch =
|
||
gridStatusFilter === 'ALL' ||
|
||
(gridStatusFilter === 'ACTIVE' && (r.status || 'ACTIVE') === 'ACTIVE') ||
|
||
(gridStatusFilter === 'STOPPED' && r.status === 'STOPPED');
|
||
|
||
const queryMatch =
|
||
!busSearchQuery.trim() ||
|
||
r.name.toLowerCase().includes(busSearchQuery.toLowerCase()) ||
|
||
r.number.toLowerCase().includes(busSearchQuery.toLowerCase()) ||
|
||
r.from.toLowerCase().includes(busSearchQuery.toLowerCase());
|
||
|
||
return statusMatch && queryMatch;
|
||
})
|
||
.map((route) => (
|
||
<motion.div
|
||
key={route.id}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className={`bg-white border rounded-3xl p-4.5 space-y-3.5 transition-all flex flex-col justify-between shadow-sm ${
|
||
route.status === 'STOPPED'
|
||
? 'border-rose-200 bg-slate-50/50 opacity-90'
|
||
: 'border-slate-200/80 hover:border-orange-300 hover:shadow-md'
|
||
}`}
|
||
>
|
||
<div>
|
||
{/* Card Header */}
|
||
<div className="flex items-start justify-between gap-2 mb-2.5">
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className="px-2.5 py-0.5 rounded-xl text-xs font-bold font-mono text-white shadow-sm shrink-0"
|
||
style={{ backgroundColor: route.color || '#FF6B00' }}
|
||
>
|
||
{route.number}
|
||
</span>
|
||
<span className="text-sm font-bold text-[#0F172A] truncate max-w-[140px]">{route.name}</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
{/* Toggle Status Pill */}
|
||
<button
|
||
onClick={() => handleToggleRouteStatus(route.id)}
|
||
className={`px-2 py-0.5 rounded-full text-[10px] font-extrabold uppercase flex items-center gap-1 border transition-all cursor-pointer ${
|
||
(route.status || 'ACTIVE') === 'ACTIVE'
|
||
? 'bg-emerald-50 text-emerald-700 border-emerald-300 hover:bg-emerald-100'
|
||
: 'bg-rose-50 text-rose-700 border-rose-300 hover:bg-rose-100'
|
||
}`}
|
||
title="Click to toggle Active / Stopped status"
|
||
>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${ (route.status || 'ACTIVE') === 'ACTIVE' ? 'bg-emerald-500 animate-pulse' : 'bg-rose-500' }`} />
|
||
{(route.status || 'ACTIVE') === 'ACTIVE' ? 'Active' : 'Stopped'}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => openEditRouteModal(route)}
|
||
className="p-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 hover:text-slate-900 transition-colors"
|
||
title="Edit route"
|
||
>
|
||
<Edit className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteRoute(route.id, route.number)}
|
||
className="p-1.5 rounded-lg bg-rose-50 hover:bg-rose-100 text-rose-600 transition-colors"
|
||
title="Delete route"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Route Path */}
|
||
<div className="space-y-1.5 text-xs py-2 border-y border-slate-100 font-medium">
|
||
<div className="flex items-center justify-between text-slate-600">
|
||
<span className="text-slate-400 font-semibold">Origin:</span>
|
||
<strong className="text-[#0F172A]">{route.from} ({route.departureTime})</strong>
|
||
</div>
|
||
<div className="flex items-center justify-between text-slate-600">
|
||
<span className="text-slate-400 font-semibold">Destination:</span>
|
||
<strong className="text-[#0F172A]">{route.to} ({route.arrivalTime})</strong>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stops Preview */}
|
||
<div className="pt-2.5">
|
||
<span className="text-[11px] uppercase font-bold text-slate-400 block mb-1.5 tracking-wider">
|
||
Intermediate Stops ({route.stops?.length || 0})
|
||
</span>
|
||
<div className="space-y-1 max-h-28 overflow-y-auto pr-1">
|
||
{route.stops && route.stops.length > 0 ? (
|
||
route.stops.map((stop, i) => (
|
||
<div key={i} className="flex items-center justify-between text-[11px] bg-slate-50 p-2 rounded-xl border border-slate-100">
|
||
<span className="text-slate-700 font-semibold truncate">{stop.name}</span>
|
||
<span className="text-[#FF6B00] font-mono font-bold text-[10px] shrink-0">{stop.time}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-[11px] text-slate-400 italic">No intermediate stops configured</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pt-2.5 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-500 font-medium">
|
||
<span>Driver PIN: <strong className="text-[#0F172A] font-mono font-bold">RITDRIVER</strong></span>
|
||
<span className={`border px-2 py-0.5 rounded-full flex items-center gap-1 font-bold text-[10px] ${
|
||
(route.status || 'ACTIVE') === 'ACTIVE'
|
||
? 'bg-emerald-50 text-emerald-700 border-emerald-200/80'
|
||
: 'bg-rose-50 text-rose-700 border-rose-200/80'
|
||
}`}>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${ (route.status || 'ACTIVE') === 'ACTIVE' ? 'bg-emerald-500 animate-pulse' : 'bg-rose-500' }`} />
|
||
{(route.status || 'ACTIVE') === 'ACTIVE' ? 'Live Sync' : 'Suspended'}
|
||
</span>
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* TAB 2: TELEGRAM Q&A SENIORS & BOT CONFIGURATION */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'telegram' && (
|
||
<div className="space-y-6">
|
||
|
||
{/* Top Status & Quick Action Ribbon */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{/* Bot Connection Card */}
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-4 shadow-sm flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-bold text-sm ${
|
||
botInfoCommunity ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-orange-50 text-[#FF6B00] border border-orange-200'
|
||
}`}>
|
||
<Bot className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-1.5">
|
||
<h4 className="text-xs font-bold text-[#0F172A]">Community Q&A Bot</h4>
|
||
<span className={`w-2 h-2 rounded-full ${botInfoCommunity ? 'bg-emerald-500 animate-pulse' : 'bg-amber-400'}`} />
|
||
</div>
|
||
{botInfoCommunity ? (
|
||
<a
|
||
href={`https://t.me/${botInfoCommunity.username}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-[11px] font-semibold text-emerald-600 hover:underline flex items-center gap-1 mt-0.5"
|
||
>
|
||
@{botInfoCommunity.username} <ExternalLink className="w-2.5 h-2.5" />
|
||
</a>
|
||
) : (
|
||
<p className="text-[11px] text-slate-500 font-medium mt-0.5">
|
||
{telegramConfig?.bot_username ? `@${telegramConfig.bot_username}` : 'Token configured'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => handleVerifyBotToken('community')}
|
||
disabled={testingBotType === 'community'}
|
||
className="px-2.5 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-[11px] font-bold transition-all cursor-pointer flex items-center gap-1"
|
||
title="Verify Bot Connection"
|
||
>
|
||
<RefreshCw className={`w-3 h-3 ${testingBotType === 'community' ? 'animate-spin' : ''}`} />
|
||
{testingBotType === 'community' ? 'Verifying...' : 'Test Bot'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Active Responders Card */}
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-4 shadow-sm flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-blue-50 text-blue-600 border border-blue-200 flex items-center justify-center font-bold text-sm">
|
||
<Users className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xs font-bold text-[#0F172A]">Active Senior Responders</h4>
|
||
<p className="text-[11px] text-slate-500 font-medium mt-0.5">
|
||
<strong className="text-[#0F172A] font-bold">
|
||
{telegramConfig?.seniorHelpers?.length || telegramConfig?.helper_chat_ids?.length || 0}
|
||
</strong> Seniors in Dispatch Roster
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<span className="px-2 py-1 rounded-lg bg-emerald-50 text-emerald-700 text-[10px] font-extrabold uppercase border border-emerald-200">
|
||
Ready
|
||
</span>
|
||
</div>
|
||
|
||
{/* Quick Network Broadcast Card */}
|
||
<div className="bg-white border border-slate-200/80 rounded-2xl p-4 shadow-sm flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-xl bg-orange-50 text-[#FF6B00] border border-orange-200 flex items-center justify-center font-bold text-sm">
|
||
<Radio className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xs font-bold text-[#0F172A]">Network Broadcast Test</h4>
|
||
<p className="text-[11px] text-slate-500 font-medium mt-0.5">Ping all responders at once</p>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleBroadcastTestAlert}
|
||
disabled={broadcastingAlert}
|
||
className="px-3 py-1.5 rounded-xl bg-[#0F172A] hover:bg-slate-800 text-white text-[11px] font-bold transition-all cursor-pointer flex items-center gap-1.5 shadow-sm"
|
||
>
|
||
<Send className={`w-3 h-3 ${broadcastingAlert ? 'animate-bounce' : ''}`} />
|
||
{broadcastingAlert ? 'Broadcasting...' : 'Broadcast'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||
|
||
{/* Left 7 Cols: Active Senior Responders & Dispatch Roster */}
|
||
<div className="lg:col-span-7 space-y-6">
|
||
|
||
{/* Section Card */}
|
||
<div className="bg-white border border-slate-200/80 rounded-3xl p-5 shadow-sm space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-base font-bold text-[#0F172A] flex items-center gap-2">
|
||
<Users className="w-4.5 h-4.5 text-[#FF6B00]" />
|
||
Senior Responders (Q&A Network)
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Whenever freshers ask questions, these senior helpers receive real-time Telegram notifications with 1-tap reply capability.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={fetchTelegramConfig}
|
||
className="p-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors cursor-pointer"
|
||
title="Refresh Telegram Helpers"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${loadingTelegram ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Add Senior Form */}
|
||
<form onSubmit={handleAddSeniorHelper} className="bg-slate-50/80 p-4 rounded-2xl border border-slate-200/80 space-y-3">
|
||
<span className="text-[11px] font-bold uppercase tracking-wider text-[#0F172A] flex items-center gap-1.5">
|
||
<Plus className="w-3.5 h-3.5 text-[#FF6B00]" />
|
||
Add New Senior Responder
|
||
</span>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2.5">
|
||
<div className="sm:col-span-4">
|
||
<input
|
||
type="text"
|
||
value={newHelperChatId}
|
||
onChange={(e) => setNewHelperChatId(e.target.value)}
|
||
placeholder="Telegram Chat ID *"
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 placeholder-slate-400 font-mono outline-none focus:border-[#FF6B00] focus:ring-2 focus:ring-orange-500/20"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="sm:col-span-5">
|
||
<input
|
||
type="text"
|
||
value={newHelperName}
|
||
onChange={(e) => setNewHelperName(e.target.value)}
|
||
placeholder="Senior Name / Note (e.g. Rahul S.)"
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 placeholder-slate-400 outline-none focus:border-[#FF6B00] focus:ring-2 focus:ring-orange-500/20 font-medium"
|
||
/>
|
||
</div>
|
||
<div className="sm:col-span-3">
|
||
<select
|
||
value={newHelperDept}
|
||
onChange={(e) => setNewHelperDept(e.target.value)}
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-2.5 py-2 text-xs text-slate-900 outline-none focus:border-[#FF6B00] font-medium"
|
||
>
|
||
<option value="CSE">CSE</option>
|
||
<option value="AIDS">AIDS</option>
|
||
<option value="IT">IT</option>
|
||
<option value="ECE">ECE</option>
|
||
<option value="EEE">EEE</option>
|
||
<option value="MECH">MECH</option>
|
||
<option value="CIVIL">CIVIL</option>
|
||
<option value="ALL">All Depts</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full py-2.5 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] text-white font-bold text-xs shadow-sm shadow-orange-500/20 transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Senior to Telegram Dispatch Network
|
||
</button>
|
||
</form>
|
||
|
||
{/* Active Seniors List */}
|
||
{(() => {
|
||
const helpersList: SeniorHelperItem[] = telegramConfig?.seniorHelpers && telegramConfig.seniorHelpers.length > 0
|
||
? telegramConfig.seniorHelpers
|
||
: (telegramConfig?.helper_chat_ids || []).map(id => ({ chatId: id, name: 'Senior Responder', department: 'CSE' }));
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wider block">
|
||
Active Responders Roster ({helpersList.length})
|
||
</span>
|
||
<span className="text-[11px] text-slate-400 font-medium">
|
||
Click <strong className="text-slate-600">⚡ Test</strong> to send an instant ping
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-2.5">
|
||
{helpersList.length > 0 ? (
|
||
helpersList.map((helper) => (
|
||
<div
|
||
key={helper.chatId}
|
||
className="bg-slate-50/70 border border-slate-200/80 rounded-2xl p-3.5 flex items-center justify-between gap-3 hover:border-slate-300 hover:bg-white transition-all shadow-xs"
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<div className="w-9 h-9 rounded-xl bg-orange-100/70 text-[#FF6B00] border border-orange-200 flex items-center justify-center shrink-0 font-extrabold text-xs">
|
||
TG
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-xs font-bold text-[#0F172A] truncate">
|
||
{helper.name || 'Senior Responder'}
|
||
</p>
|
||
{helper.department && (
|
||
<span className="px-1.5 py-0.5 rounded-md bg-slate-200/80 text-slate-700 text-[9px] font-extrabold uppercase shrink-0">
|
||
{helper.department}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<span className="text-[11px] font-mono text-slate-500 font-medium">
|
||
ID: {helper.chatId}
|
||
</span>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(helper.chatId.toString());
|
||
setCopiedChatId(helper.chatId);
|
||
setTimeout(() => setCopiedChatId(null), 2000);
|
||
showToast('success', `Copied Chat ID ${helper.chatId} to clipboard!`);
|
||
}}
|
||
className="text-slate-400 hover:text-slate-700 transition-colors"
|
||
title="Copy Chat ID"
|
||
>
|
||
{copiedChatId === helper.chatId ? (
|
||
<CheckCheck className="w-3 h-3 text-emerald-600" />
|
||
) : (
|
||
<Copy className="w-3 h-3" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{/* Test Ping Button */}
|
||
<button
|
||
onClick={() => handlePingSeniorHelper(helper.chatId, helper.name)}
|
||
disabled={pingingHelperId === helper.chatId}
|
||
className="px-2.5 py-1.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 text-xs font-bold transition-colors cursor-pointer flex items-center gap-1"
|
||
title="Send a live test message to this responder"
|
||
>
|
||
<Zap className={`w-3 h-3 text-emerald-600 ${pingingHelperId === helper.chatId ? 'animate-spin' : ''}`} />
|
||
{pingingHelperId === helper.chatId ? 'Pinging...' : 'Test'}
|
||
</button>
|
||
|
||
{/* Edit Button */}
|
||
<button
|
||
onClick={() => handleOpenEditHelper(helper)}
|
||
className="p-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors cursor-pointer"
|
||
title="Edit Responder Details"
|
||
>
|
||
<Edit className="w-3.5 h-3.5" />
|
||
</button>
|
||
|
||
{/* Remove Button */}
|
||
<button
|
||
onClick={() => handleRemoveSeniorHelper(helper.chatId)}
|
||
className="p-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 text-rose-600 transition-colors cursor-pointer"
|
||
title="Remove Responder"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-center py-8 bg-slate-50/50 rounded-2xl border border-dashed border-slate-300 space-y-2">
|
||
<Bot className="w-8 h-8 text-slate-300 mx-auto" />
|
||
<p className="text-xs text-slate-500 font-medium">No senior helpers configured yet.</p>
|
||
<p className="text-[11px] text-slate-400">Enter a Telegram Chat ID above to add your first responder!</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right 5 Cols: Live Bot Token Config, Replacement & Guides */}
|
||
<div className="lg:col-span-5 space-y-6">
|
||
|
||
{/* Live Config & Bot Token Editor */}
|
||
{telegramConfig && (
|
||
<form onSubmit={handleSaveTelegramTokens} className="bg-white border border-slate-200/80 rounded-3xl p-5 shadow-sm space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-bold text-[#0F172A] flex items-center gap-2">
|
||
<Bot className="w-4 h-4 text-[#FF6B00]" />
|
||
Telegram Bot Configuration
|
||
</h4>
|
||
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-md bg-slate-100 text-slate-600 border border-slate-200">
|
||
VPS Sync
|
||
</span>
|
||
</div>
|
||
|
||
{/* Community Q&A Bot Token */}
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[11px] font-bold text-slate-700 uppercase tracking-wider">
|
||
Community Q&A Bot Token *
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleVerifyBotToken('community')}
|
||
disabled={testingBotType === 'community'}
|
||
className="text-[10px] font-bold text-[#FF6B00] hover:underline flex items-center gap-1 cursor-pointer"
|
||
>
|
||
<Zap className="w-2.5 h-2.5" />
|
||
{testingBotType === 'community' ? 'Testing...' : 'Verify Token'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<input
|
||
type={showCommunityToken ? 'text' : 'password'}
|
||
value={telegramConfig.community_bot_token || ''}
|
||
onChange={(e) => setTelegramConfig({ ...telegramConfig, community_bot_token: e.target.value })}
|
||
placeholder="8973721012:AAG37F4Q4q584m_2aS8rT6qSWuA-WuHRGMY"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl pl-3 pr-16 py-2 text-xs font-mono text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
required
|
||
/>
|
||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCommunityToken(!showCommunityToken)}
|
||
className="p-1 text-slate-400 hover:text-slate-600 rounded-lg"
|
||
title={showCommunityToken ? 'Hide Token' : 'Show Token'}
|
||
>
|
||
{showCommunityToken ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p className="text-[10px] text-slate-400 font-medium">Dispatches question alerts to senior responders.</p>
|
||
</div>
|
||
|
||
{/* DevCollab Bot Token (Optional / Unified) */}
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-[11px] font-bold text-slate-700 uppercase tracking-wider">
|
||
DevCollab Bot Token
|
||
</label>
|
||
<span className="text-[10px] text-slate-400 font-medium">Optional</span>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<input
|
||
type={showCollabToken ? 'text' : 'password'}
|
||
value={telegramConfig.telegram_bot_token || ''}
|
||
onChange={(e) => setTelegramConfig({ ...telegramConfig, telegram_bot_token: e.target.value })}
|
||
placeholder="Leave empty to use Community Bot Token"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl pl-3 pr-10 py-2 text-xs font-mono text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCollabToken(!showCollabToken)}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 rounded-lg"
|
||
title={showCollabToken ? 'Hide Token' : 'Show Token'}
|
||
>
|
||
{showCollabToken ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Spring Backend Webhook URL */}
|
||
<div className="space-y-1.5">
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider">
|
||
Spring Backend Dispatch URL
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={telegramConfig.spring_backend_url || ''}
|
||
onChange={(e) => setTelegramConfig({ ...telegramConfig, spring_backend_url: e.target.value })}
|
||
placeholder="http://localhost:8085"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs font-mono text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={savingBotConfig}
|
||
className="w-full py-2.5 rounded-xl bg-[#0F172A] hover:bg-slate-800 text-white font-bold text-xs shadow-sm transition-colors cursor-pointer flex items-center justify-center gap-1.5"
|
||
>
|
||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||
{savingBotConfig ? 'Saving & Deploying...' : 'Save & Deploy Configuration'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{/* Interactive Setup Guides */}
|
||
<div className="bg-white border border-slate-200/80 rounded-3xl p-5 shadow-sm space-y-3.5">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-xs font-bold text-[#0F172A] flex items-center gap-1.5">
|
||
<HelpCircle className="w-4 h-4 text-[#FF6B00]" />
|
||
Telegram Setup & Guides
|
||
</h4>
|
||
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-xl text-[10px] font-bold">
|
||
<button
|
||
onClick={() => setActiveGuideTab('botfather')}
|
||
className={`px-2 py-1 rounded-lg transition-all ${
|
||
activeGuideTab === 'botfather' ? 'bg-white text-[#FF6B00] shadow-xs' : 'text-slate-600'
|
||
}`}
|
||
>
|
||
BotFather
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveGuideTab('chatid')}
|
||
className={`px-2 py-1 rounded-lg transition-all ${
|
||
activeGuideTab === 'chatid' ? 'bg-white text-[#FF6B00] shadow-xs' : 'text-slate-600'
|
||
}`}
|
||
>
|
||
Chat ID
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveGuideTab('workflow')}
|
||
className={`px-2 py-1 rounded-lg transition-all ${
|
||
activeGuideTab === 'workflow' ? 'bg-white text-[#FF6B00] shadow-xs' : 'text-slate-600'
|
||
}`}
|
||
>
|
||
Workflow
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeGuideTab === 'botfather' && (
|
||
<div className="space-y-2 text-xs text-slate-600 leading-relaxed font-medium">
|
||
<p className="font-bold text-[#0F172A]">How to create or replace a Telegram Bot:</p>
|
||
<ol className="space-y-1.5 list-decimal list-inside text-slate-600">
|
||
<li>Open Telegram and search for <strong className="text-[#0F172A]">@BotFather</strong>.</li>
|
||
<li>Send <code className="bg-slate-100 text-orange-600 px-1 py-0.5 rounded font-mono text-[11px]">/newbot</code> and choose a name & username.</li>
|
||
<li>Copy the HTTP API token provided by BotFather.</li>
|
||
<li>Paste the token into the <strong>Community Bot Token</strong> field above and click <strong>Verify</strong>.</li>
|
||
</ol>
|
||
</div>
|
||
)}
|
||
|
||
{activeGuideTab === 'chatid' && (
|
||
<div className="space-y-2 text-xs text-slate-600 leading-relaxed font-medium">
|
||
<p className="font-bold text-[#0F172A]">How seniors find their Telegram Chat ID:</p>
|
||
<ol className="space-y-1.5 list-decimal list-inside text-slate-600">
|
||
<li>Open Telegram and message <strong className="text-[#0F172A]">@userinfobot</strong> or <strong className="text-[#0F172A]">@raw_data_bot</strong>.</li>
|
||
<li>Press <strong>Start</strong> or send any message.</li>
|
||
<li>Copy the numeric <strong>Id</strong> (e.g. <code className="bg-slate-100 text-orange-600 px-1 py-0.5 rounded font-mono text-[11px]">971749136</code>).</li>
|
||
<li>Add the Chat ID in the form on the left!</li>
|
||
</ol>
|
||
</div>
|
||
)}
|
||
|
||
{activeGuideTab === 'workflow' && (
|
||
<div className="space-y-2 text-xs text-slate-600 leading-relaxed font-medium">
|
||
<p className="font-bold text-[#0F172A]">1-Tap Q&A Answer Workflow:</p>
|
||
<ul className="space-y-1.5 text-slate-600 list-disc list-inside">
|
||
<li>Fresher asks a question on Freshers Hub website.</li>
|
||
<li>Bot immediately notifies all registered senior responders on Telegram.</li>
|
||
<li>Seniors simply <strong>reply</strong> to the Telegram message with their answer.</li>
|
||
<li>The answer is instantly posted to the website community board!</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* Edit Senior Responder Modal */}
|
||
<AnimatePresence>
|
||
{editingHelper && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-xs">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white rounded-3xl p-6 max-w-md w-full shadow-2xl border border-slate-200 space-y-4"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-bold text-[#0F172A] flex items-center gap-2">
|
||
<Edit className="w-4 h-4 text-[#FF6B00]" />
|
||
Edit Senior Responder
|
||
</h3>
|
||
<button
|
||
onClick={() => setEditingHelper(null)}
|
||
className="p-1 text-slate-400 hover:text-slate-600 rounded-lg"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveEditedHelper} className="space-y-3.5">
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||
Telegram Chat ID
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editingHelper.chatId}
|
||
disabled
|
||
className="w-full bg-slate-100 border border-slate-200 rounded-xl px-3 py-2 text-xs font-mono text-slate-600 cursor-not-allowed"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||
Responder Name / Title
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={editHelperName}
|
||
onChange={(e) => setEditHelperName(e.target.value)}
|
||
placeholder="e.g. Rahul S. (CSE 4th Yr)"
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 outline-none focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">
|
||
Department
|
||
</label>
|
||
<select
|
||
value={editHelperDept}
|
||
onChange={(e) => setEditHelperDept(e.target.value)}
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 outline-none focus:border-[#FF6B00] font-medium"
|
||
>
|
||
<option value="CSE">CSE</option>
|
||
<option value="AIDS">AIDS</option>
|
||
<option value="IT">IT</option>
|
||
<option value="ECE">ECE</option>
|
||
<option value="EEE">EEE</option>
|
||
<option value="MECH">MECH</option>
|
||
<option value="CIVIL">CIVIL</option>
|
||
<option value="ALL">All Depts</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingHelper(null)}
|
||
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-bold transition-colors cursor-pointer"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] text-white text-xs font-bold transition-colors cursor-pointer"
|
||
>
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* TAB 3: COMMUNITY QUESTIONS MODERATION */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'community' && (
|
||
<div className="bg-white border border-slate-200/80 rounded-3xl p-5 shadow-sm space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-base font-bold text-[#0F172A] flex items-center gap-2">
|
||
<MessageCircle className="w-4.5 h-4.5 text-[#FF6B00]" />
|
||
Community Q&A Questions ({questions.length})
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">Moderate and delete inappropriate or spam questions.</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={fetchQuestions}
|
||
className="p-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 transition-colors cursor-pointer"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${loadingQuestions ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{loadingQuestions ? (
|
||
<div className="py-12 text-center text-xs text-slate-500 font-medium">Loading community questions...</div>
|
||
) : questions.length === 0 ? (
|
||
<div className="py-12 text-center text-xs text-slate-400 bg-slate-50 rounded-2xl font-medium">
|
||
No community questions found in database.
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{questions.map((q) => (
|
||
<div
|
||
key={q.id}
|
||
className="bg-slate-50 border border-slate-200/80 rounded-2xl p-4 flex items-start justify-between gap-3.5 hover:border-slate-300 transition-all"
|
||
>
|
||
<div className="space-y-1 min-w-0">
|
||
<h4 className="text-sm font-bold text-[#0F172A]">{q.title}</h4>
|
||
<p className="text-xs text-slate-600 font-medium line-clamp-2 leading-relaxed">{q.body}</p>
|
||
<div className="flex items-center gap-2.5 text-[11px] text-slate-500 pt-0.5">
|
||
<span>Asked by: <strong className="text-slate-800 font-bold">{q.authorName}</strong></span>
|
||
<span>•</span>
|
||
<span className="font-bold text-[#FF6B00]">Votes: {q.votes}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => handleDeleteQuestion(q.id, q.title)}
|
||
className="p-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 text-rose-600 transition-colors shrink-0 cursor-pointer"
|
||
title="Delete question"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* TAB 4: FACULTY DIRECTORY MANAGEMENT */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'faculty' && (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-5 rounded-3xl border border-slate-200/80 shadow-sm">
|
||
<div>
|
||
<h3 className="text-base font-bold text-[#0F172A] flex items-center gap-2">
|
||
<UserCheck className="w-4.5 h-4.5 text-[#FF6B00]" />
|
||
Faculty Directory Management ({facultyList.length})
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Add, update, or remove faculty profiles, cabin office hours, designations, and department assignments.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="relative">
|
||
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={facultySearch}
|
||
onChange={(e) => setFacultySearch(e.target.value)}
|
||
placeholder="Search faculty..."
|
||
className="bg-slate-50 border border-slate-300 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-900 placeholder-slate-400 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={openAddFacultyModal}
|
||
className="px-4 py-2 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] text-white font-bold text-xs shadow-sm shadow-orange-500/20 transition-all flex items-center gap-1.5 shrink-0 cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Faculty
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{facultyList
|
||
.filter((f) =>
|
||
!facultySearch.trim() ||
|
||
f.name.toLowerCase().includes(facultySearch.toLowerCase()) ||
|
||
f.department.toLowerCase().includes(facultySearch.toLowerCase()) ||
|
||
(f.designation && f.designation.toLowerCase().includes(facultySearch.toLowerCase()))
|
||
)
|
||
.map((fac) => (
|
||
<div
|
||
key={fac.id}
|
||
className="bg-white border border-slate-200/80 rounded-2xl p-4.5 space-y-3 hover:border-orange-300 hover:shadow-md transition-all flex flex-col justify-between shadow-sm"
|
||
>
|
||
<div>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<h4 className="text-xs sm:text-sm font-bold text-[#0F172A]">{fac.name}</h4>
|
||
<span className="text-[11px] font-bold text-[#FF6B00] block mt-0.5">{fac.designation}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => openEditFacultyModal(fac)}
|
||
className="p-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
|
||
>
|
||
<Edit className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteFaculty(fac.id, fac.name)}
|
||
className="p-1.5 rounded-lg bg-rose-50 hover:bg-rose-100 text-rose-600 transition-colors"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1 text-[11px] text-slate-600 font-medium pt-2 border-t border-slate-100">
|
||
<p>🎓 <strong className="text-[#0F172A]">{fac.department}</strong></p>
|
||
{fac.email && <p>✉️ <span className="text-slate-700">{fac.email}</span></p>}
|
||
{(fac.office || fac.cabin) && <p>📍 <span className="text-slate-700">Cabin: {fac.office || fac.cabin}</span></p>}
|
||
{fac.specialization && <p>⚡ <span className="text-slate-500">{fac.specialization}</span></p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* TAB 5: CLUBS & CENTERS OF EXCELLENCE MANAGEMENT */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'clubs' && (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-5 rounded-3xl border border-slate-200/80 shadow-sm">
|
||
<div>
|
||
<h3 className="text-base font-bold text-[#0F172A] flex items-center gap-2">
|
||
<Building2 className="w-4.5 h-4.5 text-[#FF6B00]" />
|
||
Clubs & Centers of Excellence ({clubsList.length})
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Manage student clubs, 15 Future Tech Centers of Excellence (CoEs), faculty lead contacts, and descriptions.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2.5">
|
||
<div className="relative">
|
||
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={clubsSearch}
|
||
onChange={(e) => setClubsSearch(e.target.value)}
|
||
placeholder="Search clubs & centers..."
|
||
className="bg-slate-50 border border-slate-300 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-900 placeholder-slate-400 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={openAddClubModal}
|
||
className="px-4 py-2 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] text-white font-bold text-xs shadow-sm shadow-orange-500/20 transition-all flex items-center gap-1.5 shrink-0 cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Club / Center
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{clubsList
|
||
.filter((c) =>
|
||
!clubsSearch.trim() ||
|
||
c.name.toLowerCase().includes(clubsSearch.toLowerCase()) ||
|
||
c.category.toLowerCase().includes(clubsSearch.toLowerCase()) ||
|
||
(c.coordinatorName && c.coordinatorName.toLowerCase().includes(clubsSearch.toLowerCase()))
|
||
)
|
||
.map((club) => (
|
||
<div
|
||
key={club.id}
|
||
className="bg-white border border-slate-200/80 rounded-2xl p-4.5 space-y-3 hover:border-orange-300 hover:shadow-md transition-all flex flex-col justify-between shadow-sm"
|
||
>
|
||
<div>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase border ${
|
||
club.type === 'Center' || club.category === 'Center of Excellence'
|
||
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||
: 'bg-blue-50 text-blue-700 border-blue-200'
|
||
}`}>
|
||
{club.type || (club.category === 'Center of Excellence' ? 'Center' : 'Club')}
|
||
</span>
|
||
</div>
|
||
<h4 className="text-xs sm:text-sm font-bold text-[#0F172A] line-clamp-1">{club.name}</h4>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
<button
|
||
onClick={() => openEditClubModal(club)}
|
||
className="p-1.5 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
|
||
>
|
||
<Edit className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteClub(club.id, club.name)}
|
||
className="p-1.5 rounded-lg bg-rose-50 hover:bg-rose-100 text-rose-600 transition-colors"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-[11px] text-slate-600 font-medium line-clamp-2 mt-1.5 leading-relaxed">{club.description}</p>
|
||
</div>
|
||
|
||
<div className="pt-2 border-t border-slate-100 text-[11px] text-slate-500 font-medium space-y-0.5">
|
||
{club.coordinatorName && <p>👤 Lead: <strong className="text-[#0F172A]">{club.coordinatorName}</strong></p>}
|
||
{club.category && <p>🏷️ Category: <span className="text-[#FF6B00] font-bold">{club.category}</span></p>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* TAB 6: GPA CALCULATOR CURRICULUM MANAGEMENT */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'curriculum' && (
|
||
<div className="space-y-5">
|
||
<div className="bg-white border border-slate-200/80 rounded-3xl p-5 shadow-sm space-y-5">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-base font-bold text-[#0F172A] flex items-center gap-2">
|
||
<Calculator className="w-4.5 h-4.5 text-[#FF6B00]" />
|
||
Department Courses & Credits Management (GPA Calculator)
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Assign and customize course codes, titles, and credit weightages for individual departments & semesters used in GPA calculations.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={openAddCourseModal}
|
||
className="px-4 py-2 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] text-white font-bold text-xs shadow-sm shadow-orange-500/20 transition-all flex items-center gap-1.5 shrink-0 cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Course to {selectedGpaDept} Sem {selectedGpaSem}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Department & Semester Selectors */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5 bg-slate-50 p-4 rounded-2xl border border-slate-200/80">
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1.5">Select Department</label>
|
||
<select
|
||
value={selectedGpaDept}
|
||
onChange={(e) => setSelectedGpaDept(e.target.value)}
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 font-bold outline-none focus:border-[#FF6B00]"
|
||
>
|
||
{['CSE', 'CSBS', 'AIML', 'AIDS', 'ECE', 'MECH', 'CIVIL', 'EEE', 'CCE', 'ECL', 'VLSI'].map((dept) => (
|
||
<option key={dept} value={dept}>{dept} - Department</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1.5">Select Semester</label>
|
||
<select
|
||
value={selectedGpaSem}
|
||
onChange={(e) => setSelectedGpaSem(Number(e.target.value))}
|
||
className="w-full bg-white border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 font-bold outline-none focus:border-[#FF6B00]"
|
||
>
|
||
{[1, 2, 3, 4, 5, 6, 7, 8].map((sem) => (
|
||
<option key={sem} value={sem}>Semester {sem}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Courses Table for Selected Department & Semester */}
|
||
<div className="space-y-3 pt-1">
|
||
{(() => {
|
||
const currentSemCourses = (curriculumMap[selectedGpaDept] && curriculumMap[selectedGpaDept][selectedGpaSem]) || (DEPARTMENT_CURRICULUM[selectedGpaDept] && DEPARTMENT_CURRICULUM[selectedGpaDept][selectedGpaSem]) || [];
|
||
|
||
return (
|
||
<>
|
||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wider block">
|
||
Configured Courses for {selectedGpaDept} - Semester {selectedGpaSem} ({currentSemCourses.length})
|
||
</span>
|
||
|
||
<div className="space-y-2">
|
||
{currentSemCourses.length > 0 ? (
|
||
currentSemCourses.map((course: any, idx: number) => (
|
||
<div
|
||
key={idx}
|
||
className="bg-slate-50 border border-slate-200/80 rounded-2xl p-3.5 flex items-center justify-between gap-3 hover:border-slate-300 transition-all"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-8 h-8 rounded-xl bg-orange-50 border border-orange-200 flex items-center justify-center text-[#FF6B00] font-bold text-xs shrink-0">
|
||
{course.credits} Cr
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-bold text-[#0F172A] flex items-center gap-2">
|
||
{course.name}
|
||
{course.isElective && (
|
||
<span className="px-2 py-0.5 rounded-full text-[9px] font-bold bg-amber-50 text-amber-700 border border-amber-200">
|
||
Elective Slot
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-[11px] text-slate-500 font-medium">Credit Weightage: {course.credits} Units</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
onClick={() => openEditCourseModal(idx, course)}
|
||
className="p-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors"
|
||
>
|
||
<Edit className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDeleteCourse(idx, course.name)}
|
||
className="p-1.5 rounded-xl bg-rose-50 hover:bg-rose-100 text-rose-600 transition-colors"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-xs text-slate-400 italic p-5 text-center bg-slate-50 rounded-2xl border border-slate-200/60">
|
||
No custom courses configured for {selectedGpaDept} Semester {selectedGpaSem} yet. Click "Add Course" above!
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* MODAL: CREATE / EDIT BUS ROUTE */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
<AnimatePresence>
|
||
{isRouteModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm overflow-y-auto">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white border border-slate-200 rounded-3xl p-6 sm:p-8 max-w-xl w-full max-h-[90vh] overflow-y-auto shadow-2xl space-y-6"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
|
||
<h3 className="text-lg font-extrabold text-[#0F172A] flex items-center gap-2.5">
|
||
<Bus className="w-5 h-5 text-[#FF6B00]" />
|
||
{editingRoute ? `Edit Route ${editingRoute.number}` : 'Create New Bus Route'}
|
||
</h3>
|
||
<button
|
||
onClick={() => setIsRouteModalOpen(false)}
|
||
className="p-2 rounded-xl bg-slate-100 text-slate-400 hover:text-slate-700 hover:bg-slate-200 transition-colors font-bold text-sm"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveRoute} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Route Number *</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.number}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, number: e.target.value })}
|
||
placeholder="e.g. RIT-05"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-bold"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Route Name *</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.name}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, name: e.target.value })}
|
||
placeholder="e.g. Tambaram Super Express"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Start Point (Origin) *</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.from}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, from: e.target.value })}
|
||
placeholder="e.g. Tambaram"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">End Point (Destination) *</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.to}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, to: e.target.value })}
|
||
placeholder="e.g. RIT Campus"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Departure Time</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.departureTime}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, departureTime: e.target.value })}
|
||
placeholder="07:00 AM"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 font-mono outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Arrival Time</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.arrivalTime}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, arrivalTime: e.target.value })}
|
||
placeholder="08:30 AM"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 font-mono outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Badge Color</label>
|
||
<input
|
||
type="text"
|
||
value={routeFormData.color}
|
||
onChange={(e) => setRouteFormData({ ...routeFormData, color: e.target.value })}
|
||
placeholder="#FF6B00"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2.5 text-xs font-mono text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stops Builder */}
|
||
<div className="space-y-2.5 pt-3 border-t border-slate-100">
|
||
<div className="flex items-center justify-between">
|
||
<label className="text-xs font-extrabold text-[#0F172A] uppercase tracking-wider">Route Stops & Timings</label>
|
||
<button
|
||
type="button"
|
||
onClick={addStopField}
|
||
className="text-xs text-[#FF6B00] font-bold hover:underline flex items-center gap-1 cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Stop
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||
{routeFormData.stops.map((stop, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<span className="w-6 text-center text-xs font-mono text-slate-400 font-bold">{i + 1}.</span>
|
||
<input
|
||
type="text"
|
||
value={stop.name}
|
||
onChange={(e) => {
|
||
const updated = [...routeFormData.stops];
|
||
updated[i].name = e.target.value;
|
||
setRouteFormData({ ...routeFormData, stops: updated });
|
||
}}
|
||
placeholder="Stop Name (e.g. Chrompet)"
|
||
className="flex-1 bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2 text-xs text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={stop.time}
|
||
onChange={(e) => {
|
||
const updated = [...routeFormData.stops];
|
||
updated[i].time = e.target.value;
|
||
setRouteFormData({ ...routeFormData, stops: updated });
|
||
}}
|
||
placeholder="07:20 AM"
|
||
className="w-24 bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 font-mono outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeStopField(i)}
|
||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-50 cursor-pointer"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsRouteModalOpen(false)}
|
||
className="px-5 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-bold text-slate-700 border border-slate-200 transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-6 py-2.5 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] text-xs font-bold text-white shadow-md shadow-orange-500/20 cursor-pointer transition-all"
|
||
>
|
||
{editingRoute ? 'Save Changes' : 'Create Route'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* MODAL: CREATE / EDIT FACULTY */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
<AnimatePresence>
|
||
{isFacultyModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm overflow-y-auto">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white border border-slate-200 rounded-3xl p-6 sm:p-8 max-w-lg w-full shadow-2xl space-y-5"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 pb-3.5">
|
||
<h3 className="text-base font-extrabold text-[#0F172A] flex items-center gap-2">
|
||
<UserCheck className="w-5 h-5 text-[#FF6B00]" />
|
||
{editingFaculty ? `Edit Faculty: ${editingFaculty.name}` : 'Add New Faculty Member'}
|
||
</h3>
|
||
<button onClick={() => setIsFacultyModalOpen(false)} className="text-slate-400 hover:text-slate-700 text-base font-bold">✕</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveFaculty} className="space-y-4 text-xs">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Full Name *</label>
|
||
<input
|
||
type="text"
|
||
value={facultyFormData.name}
|
||
onChange={(e) => setFacultyFormData({ ...facultyFormData, name: e.target.value })}
|
||
placeholder="e.g. Dr. ARTHI A."
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3.5">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Designation</label>
|
||
<input
|
||
type="text"
|
||
value={facultyFormData.designation}
|
||
onChange={(e) => setFacultyFormData({ ...facultyFormData, designation: e.target.value })}
|
||
placeholder="e.g. Associate Professor & HOD"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Department</label>
|
||
<input
|
||
type="text"
|
||
value={facultyFormData.department}
|
||
onChange={(e) => setFacultyFormData({ ...facultyFormData, department: e.target.value })}
|
||
placeholder="Computer Science & Engineering"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3.5">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Email</label>
|
||
<input
|
||
type="email"
|
||
value={facultyFormData.email}
|
||
onChange={(e) => setFacultyFormData({ ...facultyFormData, email: e.target.value })}
|
||
placeholder="arthi.a@ritchennai.edu.in"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Cabin / Office Room</label>
|
||
<input
|
||
type="text"
|
||
value={facultyFormData.office}
|
||
onChange={(e) => setFacultyFormData({ ...facultyFormData, office: e.target.value })}
|
||
placeholder="CSE HOD Cabin, 2nd Floor"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Specialization / Domain</label>
|
||
<input
|
||
type="text"
|
||
value={facultyFormData.specialization}
|
||
onChange={(e) => setFacultyFormData({ ...facultyFormData, specialization: e.target.value })}
|
||
placeholder="Machine Learning, Deep Learning, Cloud Computing"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 pt-3.5 border-t border-slate-100">
|
||
<button type="button" onClick={() => setIsFacultyModalOpen(false)} className="px-5 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-bold text-slate-700 border border-slate-200">Cancel</button>
|
||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] font-bold text-xs text-white shadow-md shadow-orange-500/20 cursor-pointer">Save Faculty</button>
|
||
</div>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* MODAL: CREATE / EDIT CLUB OR CENTER */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
<AnimatePresence>
|
||
{isClubModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm overflow-y-auto">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white border border-slate-200 rounded-3xl p-6 sm:p-8 max-w-lg w-full shadow-2xl space-y-5"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 pb-3.5">
|
||
<h3 className="text-base font-extrabold text-[#0F172A] flex items-center gap-2">
|
||
<Building2 className="w-5 h-5 text-[#FF6B00]" />
|
||
{editingClub ? `Edit: ${editingClub.name}` : 'Add New Club / Center of Excellence'}
|
||
</h3>
|
||
<button onClick={() => setIsClubModalOpen(false)} className="text-slate-400 hover:text-slate-700 text-base font-bold">✕</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveClub} className="space-y-4 text-xs">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Name *</label>
|
||
<input
|
||
type="text"
|
||
value={clubFormData.name}
|
||
onChange={(e) => setClubFormData({ ...clubFormData, name: e.target.value })}
|
||
placeholder="e.g. Center for Artificial Intelligence & Deep Learning"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3.5">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Type</label>
|
||
<select
|
||
value={clubFormData.type}
|
||
onChange={(e) => setClubFormData({ ...clubFormData, type: e.target.value as any })}
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 font-bold outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
>
|
||
<option value="Center">Center of Excellence (CoE)</option>
|
||
<option value="Club">Student Club</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Category</label>
|
||
<input
|
||
type="text"
|
||
value={clubFormData.category}
|
||
onChange={(e) => setClubFormData({ ...clubFormData, category: e.target.value })}
|
||
placeholder="Center of Excellence / Technical / Cultural"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Description *</label>
|
||
<textarea
|
||
rows={2}
|
||
value={clubFormData.description}
|
||
onChange={(e) => setClubFormData({ ...clubFormData, description: e.target.value })}
|
||
placeholder="Short description of research domain or club activities..."
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3.5">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Faculty Lead / Coordinator</label>
|
||
<input
|
||
type="text"
|
||
value={clubFormData.coordinatorName}
|
||
onChange={(e) => setClubFormData({ ...clubFormData, coordinatorName: e.target.value })}
|
||
placeholder="e.g. Dr. ARTHI A."
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Contact Email</label>
|
||
<input
|
||
type="email"
|
||
value={clubFormData.contactEmail}
|
||
onChange={(e) => setClubFormData({ ...clubFormData, contactEmail: e.target.value })}
|
||
placeholder="coe.ai@ritchennai.edu.in"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 pt-3.5 border-t border-slate-100">
|
||
<button type="button" onClick={() => setIsClubModalOpen(false)} className="px-5 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-bold text-slate-700 border border-slate-200">Cancel</button>
|
||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] font-bold text-xs text-white shadow-md shadow-orange-500/20 cursor-pointer">Save Entry</button>
|
||
</div>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* MODAL: CREATE / EDIT GPA COURSE */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
<AnimatePresence>
|
||
{isCourseModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm overflow-y-auto">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white border border-slate-200 rounded-3xl p-6 sm:p-8 max-w-md w-full shadow-2xl space-y-5"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 pb-3.5">
|
||
<h3 className="text-base font-extrabold text-[#0F172A] flex items-center gap-2">
|
||
<Calculator className="w-5 h-5 text-[#FF6B00]" />
|
||
{editingCourseIndex !== null ? 'Edit Course' : `Add Course to ${selectedGpaDept} Sem ${selectedGpaSem}`}
|
||
</h3>
|
||
<button onClick={() => setIsCourseModalOpen(false)} className="text-slate-400 hover:text-slate-700 text-base font-bold">✕</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveCourse} className="space-y-4 text-xs">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Course Code & Title *</label>
|
||
<input
|
||
type="text"
|
||
value={courseFormData.name}
|
||
onChange={(e) => setCourseFormData({ ...courseFormData, name: e.target.value })}
|
||
placeholder="e.g. CS3301 - Data Structures"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-medium"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3.5">
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wider mb-1.5">Credit Weightage (1-6)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={6}
|
||
value={courseFormData.credits}
|
||
onChange={(e) => setCourseFormData({ ...courseFormData, credits: Number(e.target.value) })}
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00] font-mono font-bold"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center pt-5">
|
||
<label className="flex items-center gap-2 text-xs text-slate-700 font-bold cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={courseFormData.isElective}
|
||
onChange={(e) => setCourseFormData({ ...courseFormData, isElective: e.target.checked })}
|
||
className="rounded border-slate-300 bg-slate-50 text-[#FF6B00] focus:ring-[#FF6B00] w-4 h-4"
|
||
/>
|
||
<span>Professional Elective</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-3 pt-3.5 border-t border-slate-100">
|
||
<button type="button" onClick={() => setIsCourseModalOpen(false)} className="px-5 py-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-bold text-slate-700 border border-slate-200">Cancel</button>
|
||
<button type="submit" className="px-6 py-2.5 rounded-xl bg-[#FF6B00] hover:bg-[#EA580C] font-bold text-xs text-white shadow-md shadow-orange-500/20 cursor-pointer">Save Course</button>
|
||
</div>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* MODAL: MERGE BUS ROUTES */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
<AnimatePresence>
|
||
{isMergeModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm overflow-y-auto">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white border border-slate-200 rounded-3xl p-6 sm:p-7 max-w-3xl w-full shadow-2xl space-y-5 my-8 max-h-[90vh] flex flex-col justify-between"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 pb-3 shrink-0">
|
||
<div>
|
||
<h3 className="text-base font-extrabold text-[#0F172A] flex items-center gap-2">
|
||
<GitMerge className="w-5 h-5 text-[#FF6B00]" />
|
||
Merge Bus Routes (Low-Occupancy Attachment)
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Select 2 or more buses to attach low-occupancy routes together. All intermediate stops will be auto-combined for custom editing.
|
||
</p>
|
||
</div>
|
||
<button onClick={() => setIsMergeModalOpen(false)} className="text-slate-400 hover:text-slate-700 text-base font-bold shrink-0">✕</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveMergedRoute} className="space-y-5 overflow-y-auto pr-1 flex-1 text-xs">
|
||
|
||
{/* Step 1: Route Selection Checklist */}
|
||
<div className="space-y-2.5">
|
||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wider block">
|
||
1. Select Bus Routes to Merge ({selectedRouteIdsForMerge.length} selected) *
|
||
</span>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5 max-h-48 overflow-y-auto p-3 bg-slate-50 border border-slate-200 rounded-2xl">
|
||
{routes.map((route) => {
|
||
const isChecked = selectedRouteIdsForMerge.includes(route.id);
|
||
return (
|
||
<label
|
||
key={route.id}
|
||
className={`flex items-center gap-2.5 p-2.5 rounded-xl border transition-all cursor-pointer ${
|
||
isChecked
|
||
? 'bg-orange-50/80 border-[#FF6B00] shadow-sm'
|
||
: 'bg-white border-slate-200 hover:border-slate-300'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isChecked}
|
||
onChange={() => toggleSelectRouteForMerge(route.id)}
|
||
className="rounded border-slate-300 text-[#FF6B00] focus:ring-[#FF6B00] w-4 h-4"
|
||
/>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-bold text-slate-900 truncate">
|
||
<span className="font-mono text-[#FF6B00] mr-1">[{route.number}]</span>
|
||
{route.name}
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 font-medium">
|
||
{route.from} ({route.departureTime}) → {route.stops?.length || 0} stops
|
||
</p>
|
||
</div>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 2: Merged Route Configuration Details */}
|
||
{selectedRouteIdsForMerge.length >= 2 && (
|
||
<div className="space-y-4 pt-2 border-t border-slate-100">
|
||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wider block">
|
||
2. Combined Route Configuration
|
||
</span>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">Merged Route # *</label>
|
||
<input
|
||
type="text"
|
||
value={mergedRouteFormData.number}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, number: e.target.value })}
|
||
placeholder="e.g. R11-MERGED"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2 text-xs font-mono font-bold text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">Merged Route Name *</label>
|
||
<input
|
||
type="text"
|
||
value={mergedRouteFormData.name}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, name: e.target.value })}
|
||
placeholder="e.g. Ennore + Tondiarpet Combined Express"
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3.5 py-2 text-xs font-bold text-slate-900 outline-none focus:bg-white focus:border-[#FF6B00]"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">Origin</label>
|
||
<input
|
||
type="text"
|
||
value={mergedRouteFormData.from}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, from: e.target.value })}
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs font-medium text-slate-900"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">Destination</label>
|
||
<input
|
||
type="text"
|
||
value={mergedRouteFormData.to}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, to: e.target.value })}
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs font-medium text-slate-900"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">Departure</label>
|
||
<input
|
||
type="text"
|
||
value={mergedRouteFormData.departureTime}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, departureTime: e.target.value })}
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs font-mono font-medium text-slate-900"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-bold text-slate-700 uppercase tracking-wider mb-1">Arrival</label>
|
||
<input
|
||
type="text"
|
||
value={mergedRouteFormData.arrivalTime}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, arrivalTime: e.target.value })}
|
||
className="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs font-mono font-medium text-slate-900"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 pt-1">
|
||
<label className="flex items-center gap-2 text-xs text-slate-700 font-bold cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={mergedRouteFormData.deactivateOriginals}
|
||
onChange={(e) => setMergedRouteFormData({ ...mergedRouteFormData, deactivateOriginals: e.target.checked })}
|
||
className="rounded border-slate-300 text-[#FF6B00] focus:ring-[#FF6B00] w-4 h-4"
|
||
/>
|
||
<span>Deactivate original source routes after creating merged route</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Step 3: Combined Stops List with Deletion */}
|
||
<div className="space-y-2.5 pt-3 border-t border-slate-100">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[11px] font-bold text-slate-700 uppercase tracking-wider block">
|
||
3. Combined Intermediate Stops ({mergedRouteFormData.stops.length} stops)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddMergedStopField}
|
||
className="text-[11px] font-bold text-[#FF6B00] hover:text-[#EA580C] flex items-center gap-1 cursor-pointer"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
Add Extra Stop
|
||
</button>
|
||
</div>
|
||
|
||
<p className="text-[11px] text-slate-500 font-medium italic">
|
||
All stops from the selected buses are listed below. Click the <span className="text-rose-600 font-bold">✕</span> icon to remove any duplicate or unwanted stops today.
|
||
</p>
|
||
|
||
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||
{mergedRouteFormData.stops.length > 0 ? (
|
||
mergedRouteFormData.stops.map((stop, idx) => (
|
||
<div key={idx} className="flex items-center gap-2.5 bg-slate-50 p-2.5 rounded-xl border border-slate-200">
|
||
<span className="px-2 py-0.5 rounded-lg bg-orange-100 text-[#FF6B00] font-mono font-bold text-[10px] shrink-0">
|
||
{stop.sourceRoute || 'Custom'}
|
||
</span>
|
||
<input
|
||
type="text"
|
||
value={stop.name}
|
||
onChange={(e) => {
|
||
const updated = [...mergedRouteFormData.stops];
|
||
updated[idx].name = e.target.value;
|
||
setMergedRouteFormData({ ...mergedRouteFormData, stops: updated });
|
||
}}
|
||
placeholder="Stop Name"
|
||
className="flex-1 bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-900 outline-none focus:border-[#FF6B00]"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={stop.time}
|
||
onChange={(e) => {
|
||
const updated = [...mergedRouteFormData.stops];
|
||
updated[idx].time = e.target.value;
|
||
setMergedRouteFormData({ ...mergedRouteFormData, stops: updated });
|
||
}}
|
||
placeholder="Time"
|
||
className="w-24 bg-white border border-slate-300 rounded-lg px-2 py-1 text-xs font-mono font-semibold text-slate-900 outline-none focus:border-[#FF6B00]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveMergedStop(idx)}
|
||
className="p-1 rounded-lg text-rose-500 hover:text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer shrink-0"
|
||
title="Delete this stop from merged route"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="py-6 text-center text-xs text-slate-400 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||
Select 2 or more routes above to automatically pull and combine stops.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-100 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsMergeModalOpen(false)}
|
||
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-xs font-bold text-slate-700 border border-slate-200 cursor-pointer"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={selectedRouteIdsForMerge.length < 2}
|
||
className={`px-5 py-2.5 rounded-xl font-bold text-xs text-white shadow-md transition-all cursor-pointer flex items-center gap-1.5 ${
|
||
selectedRouteIdsForMerge.length >= 2
|
||
? 'bg-gradient-to-r from-[#FF6B00] to-[#EA580C] hover:from-[#EA580C] hover:to-[#D97706] shadow-orange-500/20'
|
||
: 'bg-slate-300 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
<GitMerge className="w-3.5 h-3.5" />
|
||
Save Merged Bus Route
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
{/* MODAL: BUS STATUS MANAGER (ACTIVE VS STOPPED) */}
|
||
{/* ───────────────────────────────────────────────────────────── */}
|
||
<AnimatePresence>
|
||
{isBusStatusModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm overflow-y-auto">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
className="bg-white border border-slate-200 rounded-3xl p-6 sm:p-7 max-w-2xl w-full shadow-2xl space-y-5 my-8 max-h-[88vh] flex flex-col justify-between"
|
||
>
|
||
<div className="flex items-center justify-between border-b border-slate-100 pb-3 shrink-0">
|
||
<div>
|
||
<h3 className="text-base font-extrabold text-[#0F172A] flex items-center gap-2">
|
||
<Power className="w-5 h-5 text-amber-500" />
|
||
Bus Fleet Status Manager
|
||
</h3>
|
||
<p className="text-xs text-slate-500 font-medium mt-0.5">
|
||
Toggle service status (Active 🟢 vs Stopped 🔴) for individual buses or merged routes without deleting them.
|
||
</p>
|
||
</div>
|
||
<button onClick={() => setIsBusStatusModalOpen(false)} className="text-slate-400 hover:text-slate-700 text-base font-bold shrink-0">✕</button>
|
||
</div>
|
||
|
||
{/* Status Controls & Quick Bulk Actions */}
|
||
<div className="space-y-3 shrink-0">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 bg-slate-50 p-3 rounded-2xl border border-slate-200">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-wider mr-1">Filter:</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBusStatusTabFilter('ALL')}
|
||
className={`px-3 py-1 rounded-xl text-xs font-bold transition-all ${
|
||
busStatusTabFilter === 'ALL' ? 'bg-[#0F172A] text-white shadow-sm' : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
|
||
}`}
|
||
>
|
||
All ({routes.length})
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBusStatusTabFilter('ACTIVE')}
|
||
className={`px-3 py-1 rounded-xl text-xs font-bold transition-all ${
|
||
busStatusTabFilter === 'ACTIVE' ? 'bg-emerald-600 text-white shadow-sm' : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
|
||
}`}
|
||
>
|
||
Active ({routes.filter(r => (r.status || 'ACTIVE') === 'ACTIVE').length})
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBusStatusTabFilter('STOPPED')}
|
||
className={`px-3 py-1 rounded-xl text-xs font-bold transition-all ${
|
||
busStatusTabFilter === 'STOPPED' ? 'bg-rose-600 text-white shadow-sm' : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-200'
|
||
}`}
|
||
>
|
||
Stopped ({routes.filter(r => r.status === 'STOPPED').length})
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetAllRoutesStatus('ACTIVE')}
|
||
className="px-2.5 py-1 rounded-xl bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 text-[11px] font-bold transition-all"
|
||
>
|
||
Activate All
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSetAllRoutesStatus('STOPPED')}
|
||
className="px-2.5 py-1 rounded-xl bg-rose-50 text-rose-700 hover:bg-rose-100 border border-rose-200 text-[11px] font-bold transition-all"
|
||
>
|
||
Stop All
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Route List with Status Toggles */}
|
||
<div className="space-y-2 max-h-80 overflow-y-auto pr-1 flex-1">
|
||
{routes
|
||
.filter(r => {
|
||
if (busStatusTabFilter === 'ACTIVE') return (r.status || 'ACTIVE') === 'ACTIVE';
|
||
if (busStatusTabFilter === 'STOPPED') return r.status === 'STOPPED';
|
||
return true;
|
||
})
|
||
.map((route) => {
|
||
const isActive = (route.status || 'ACTIVE') === 'ACTIVE';
|
||
return (
|
||
<div
|
||
key={route.id}
|
||
className={`flex items-center justify-between p-3.5 rounded-2xl border transition-all ${
|
||
isActive
|
||
? 'bg-white border-slate-200 hover:border-slate-300'
|
||
: 'bg-rose-50/40 border-rose-200'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<span
|
||
className="px-2.5 py-1 rounded-xl text-xs font-bold font-mono text-white shadow-sm shrink-0"
|
||
style={{ backgroundColor: route.color || '#FF6B00' }}
|
||
>
|
||
{route.number}
|
||
</span>
|
||
<div>
|
||
<p className="text-xs font-bold text-slate-900 flex items-center gap-2">
|
||
<span>{route.name}</span>
|
||
{route.number.includes('MERGED') && (
|
||
<span className="px-2 py-0.5 rounded-full text-[9px] font-extrabold uppercase bg-orange-100 text-[#FF6B00] border border-orange-200">
|
||
Merged Route
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-[11px] text-slate-500 font-medium">
|
||
{route.from} ({route.departureTime}) → {route.to} ({route.stops?.length || 0} stops)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleToggleRouteStatus(route.id)}
|
||
className={`px-3.5 py-1.5 rounded-xl font-bold text-xs flex items-center gap-1.5 border transition-all cursor-pointer ${
|
||
isActive
|
||
? 'bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 border-emerald-600'
|
||
: 'bg-rose-600 hover:bg-rose-700 text-white shadow-sm shadow-rose-600/20 border-rose-600'
|
||
}`}
|
||
>
|
||
<Power className="w-3.5 h-3.5" />
|
||
{isActive ? 'Active 🟢' : 'Stopped 🔴'}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between pt-3.5 border-t border-slate-100 shrink-0">
|
||
<span className="text-[11px] font-medium text-slate-500">
|
||
Tip: Setting source buses (e.g. R11 & R11A) to 🔴 Stopped lets you reactivate them next week instantly!
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsBusStatusModalOpen(false)}
|
||
className="px-5 py-2 rounded-xl bg-[#0F172A] text-white text-xs font-bold hover:bg-slate-800 transition-colors"
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|