From df3be3cb8343dad066b4583fc75ac6993090c008 Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 9 Jun 2026 18:59:30 +0530 Subject: [PATCH 1/4] feat:Implement the admin panel with basic features --- .../features/admin/components/AdminLayout.tsx | 115 +++++++ .../components/DeleteLibrarianProfile.tsx | 82 +++++ .../admin/components/DeleteUserModal.tsx | 84 +++++ .../admin/components/LibrarianModal.tsx | 256 +++++++++++++++ .../admin/components/LibrarianProfile.tsx | 154 +++++++++ .../admin/components/ManageLibrarians.tsx | 95 ++++++ .../features/admin/components/ManageUsers.tsx | 126 ++++++++ .../admin/components/UserDetailsModal.tsx | 294 ++++++++++++++++++ .../features/admin/components/UserModal.tsx | 251 +++++++++++++++ .../src/features/admin/pages/AdminPanel.tsx | 53 ++++ client/src/features/auth/pages/Login.tsx | 107 ++++--- client/src/index.css | 6 +- client/src/routes/AppRoutes.tsx | 33 +- client/src/routes/ProtectedGuard.tsx | 24 +- client/src/routes/PublicGuard.tsx | 15 +- server/src/modules/admin/admin.controller.ts | 98 ++++++ server/src/modules/admin/admin.repository.ts | 33 ++ server/src/modules/admin/admin.routes.ts | 20 ++ server/src/modules/admin/admin.service.ts | 86 +++++ server/src/modules/admin/admin.types.ts | 19 ++ server/src/modules/admin/admin.validation.ts | 68 ++++ server/src/routes/index.ts | 10 +- 22 files changed, 1961 insertions(+), 68 deletions(-) create mode 100644 client/src/features/admin/components/AdminLayout.tsx create mode 100644 client/src/features/admin/components/DeleteLibrarianProfile.tsx create mode 100644 client/src/features/admin/components/DeleteUserModal.tsx create mode 100644 client/src/features/admin/components/LibrarianModal.tsx create mode 100644 client/src/features/admin/components/LibrarianProfile.tsx create mode 100644 client/src/features/admin/components/ManageLibrarians.tsx create mode 100644 client/src/features/admin/components/ManageUsers.tsx create mode 100644 client/src/features/admin/components/UserDetailsModal.tsx create mode 100644 client/src/features/admin/components/UserModal.tsx create mode 100644 client/src/features/admin/pages/AdminPanel.tsx create mode 100644 server/src/modules/admin/admin.controller.ts create mode 100644 server/src/modules/admin/admin.repository.ts create mode 100644 server/src/modules/admin/admin.routes.ts create mode 100644 server/src/modules/admin/admin.service.ts create mode 100644 server/src/modules/admin/admin.types.ts create mode 100644 server/src/modules/admin/admin.validation.ts diff --git a/client/src/features/admin/components/AdminLayout.tsx b/client/src/features/admin/components/AdminLayout.tsx new file mode 100644 index 0000000..f052ab7 --- /dev/null +++ b/client/src/features/admin/components/AdminLayout.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useAuthStore } from "../../../store/authStore"; +import { Users, ShieldAlert, LogOut, LayoutDashboard } from "lucide-react"; +import { motion } from "framer-motion"; + +export const AdminLayout: React.FC = () => { + const { user, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleSignOut = () => { + logout(); + navigate("/login"); + }; + + return ( +
+ {/* Admin Sidebar Layout Navigation Container */} + + + {/* Main Structural Viewport Area */} +
+
+
+

Admin Panel

+

Library Management System

+
+
+
+

Logged in as: {user?.email}

+

Authorization: {user?.role}

+
+
+ ๐Ÿ›ก๏ธ +
+
+
+ + {/* Content View Injection Portal with Canvas Animation Layouts */} +
+ + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/DeleteLibrarianProfile.tsx b/client/src/features/admin/components/DeleteLibrarianProfile.tsx new file mode 100644 index 0000000..4c2290f --- /dev/null +++ b/client/src/features/admin/components/DeleteLibrarianProfile.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { AlertTriangle, Trash2, X } from "lucide-react"; + +interface DeleteLibrarianProfileProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + librarianName: string; + isPending: boolean; +} + +export const DeleteLibrarianProfile: React.FC = ({ + isOpen, + onClose, + onConfirm, + librarianName, + isPending, +}) => { + if (!isOpen) return null; + + return ( +
+
+ + {/* Urgent Attention Header */} +
+
+ +
+

+ Delete Librarain Account +

+ +
+ + {/* Warning Details Body */} +
+

+ Are you completely confident about deleting "{librarianName}" of all administrative privileges and access layers? +

+
+

+ โš ๏ธ Reminder +

+

+ This completely cleanses their profile registry. They will instantly lose access to management terminals. +

+
+
+ + {/* Control Footer Tray */} +
+ + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/DeleteUserModal.tsx b/client/src/features/admin/components/DeleteUserModal.tsx new file mode 100644 index 0000000..3eb3511 --- /dev/null +++ b/client/src/features/admin/components/DeleteUserModal.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { AlertTriangle, Trash2, X } from "lucide-react"; + +interface DeleteUserModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + userName: string; + isPending: boolean; +} + +export const DeleteUserModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + userName, + isPending, +}) => { + if (!isOpen) return null; + + return ( +
+
+ + {/* Warning Accent Strip */} +
+
+ +
+
+

+ Confirm Delete +

+
+ +
+ + {/* Message Core Body */} +
+

+ Are you sure want to completely delete "{userName}" from the library management databases? +

+
+

+ โš ๏ธ Irreversible Action +

+

+ This will instantly clean out their active profile, active rental parameters, and systemic histories. +

+
+
+ + {/* Dynamic Action Buttons */} +
+ + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/LibrarianModal.tsx b/client/src/features/admin/components/LibrarianModal.tsx new file mode 100644 index 0000000..b32c061 --- /dev/null +++ b/client/src/features/admin/components/LibrarianModal.tsx @@ -0,0 +1,256 @@ +import React, { useState } from "react"; +import { X, User, Mail, Lock, Phone, ShieldAlert } from "lucide-react"; +import { toast } from "sonner"; +import { axiosClient } from "../../../api/axiosClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + password?: string; + created_at: string; + role: "READER" | "LIBRARIAN"; +} + +interface BackendErrorResponse { + success: boolean; + message: string; +} + +interface LibrarianModalProps { + isOpen: boolean; + onClose: () => void; + librarianToEdit?: UserRecord | null; +} + +export const LibrarianModal: React.FC = ({ isOpen, onClose, librarianToEdit }) => { + const queryClient = useQueryClient(); + const isEditMode = !!librarianToEdit; + + // ๐Ÿ’ก INITIAL STATES (Derived safely from props) + const [name, setName] = useState(librarianToEdit?.name || ""); + const [gmail, setGmail] = useState(librarianToEdit?.gmail || ""); + const [password, setPassword] = useState(librarianToEdit?.password || ""); + const [phoneNumber, setPhoneNumber] = useState(librarianToEdit?.phone_number || ""); + const [errors, setErrors] = useState>({}); + + // ๐Ÿ’ก CLEAN FIX: handleResetAndClose now safely manages state resets directly + const handleResetAndClose = () => { + if (!isEditMode) { + setName(""); + setGmail(""); + setPassword(""); + setPhoneNumber(""); + } else { + // Revert fields back to original prop values if the admin cancels editing + setName(librarianToEdit?.name || ""); + setGmail(librarianToEdit?.gmail || ""); + setPassword(librarianToEdit?.password || ""); + setPhoneNumber(librarianToEdit?.phone_number || ""); + } + setErrors({}); + onClose(); + }; + + // MUTATION HANDLER + const librarianMutation = useMutation({ + mutationFn: async (payload: Record) => { + if (isEditMode) { + const response = await axiosClient.patch(`/admin/librarian/${librarianToEdit?.user_id}`, payload); + return response.data; + } else { + const response = await axiosClient.post("/admin/add-librarian", payload); + return response.data; + } + }, + onSuccess: () => { + toast.success( + isEditMode + ? "Officer metrics updated successfully." + : "New administrative terminal clearance provisioned." + ); + queryClient.invalidateQueries({ queryKey: ["adminLibrariansMasterFeed"] }); + handleResetAndClose(); + }, + onError: (error: AxiosError) => { + toast.error(error.response?.data?.message || "Failed to commit librarian adjustments."); + }, + }); + + const validateForm = () => { + const localErrors: Record = {}; + + if (!name.trim()) localErrors.name = "Full operational name is mandatory."; + + const gmailRegex = /^[a-z0-9](\.?[a-z0-9]){4,29}@gmail\.com$/; + if (!gmail.trim()) { + localErrors.gmail = "Routing handle tracking input required."; + } else if (!gmailRegex.test(gmail.toLowerCase())) { + localErrors.gmail = "Must register a valid structured @gmail.com handle."; + } + + const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\W_]{8,}$/; + if (!password && !isEditMode) { + localErrors.password = "Initial assignment authorization key required."; + } else if (password && !passwordRegex.test(password)) { + localErrors.password = "Must contain 8+ characters, with uppercase, lowercase, and numeric parameters."; + } + + const phoneRegex = /^\d{10}$/; + if (!phoneNumber) { + localErrors.phoneNumber = "Connectivity line mapping trace required."; + } else if (!phoneRegex.test(phoneNumber)) { + localErrors.phoneNumber = "Requires strict 10-digit numeric character string."; + } + + setErrors(localErrors); + return Object.keys(localErrors).length === 0; + }; + + const handleSubmission = (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + const payload: Record = { + name: name.trim(), + gmail: gmail.trim().toLowerCase(), + phone_number: phoneNumber, + role: "LIBRARIAN", + }; + + if (password) payload.password = password; + + librarianMutation.mutate(payload); + }; + + if (!isOpen) return null; + + return ( +
+
+ + {/* Header Layout Block */} +
+
+

+ {isEditMode ? "Modify Officer Metrics" : "Provision New Library Officer"} +

+

+ {isEditMode ? "Adjust system operational parameters" : "All fields below are strictly required"} +

+
+ +
+ + {/* Input Form Fields Wrapper */} +
+ + {/* Name Field */} +
+ +
+ + setName(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.name ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.name &&

{errors.name}

} +
+ + {/* Email Field */} +
+ +
+ + setGmail(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.gmail ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.gmail &&

{errors.gmail}

} +
+ + {/* Plain Text Password Field */} +
+ +
+ + setPassword(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold font-data tracking-wide transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.password ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.password &&

{errors.password}

} +
+ + {/* Phone Number Field */} +
+ +
+ + setPhoneNumber(e.target.value.replace(/\D/g, ""))} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.phoneNumber ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.phoneNumber &&

{errors.phoneNumber}

} +
+ + {/* Automated System Configuration Alert Badge */} +
+ + Enforced Authority Level : LIBRARIAN +
+ + {/* Trigger Actions Button Row */} +
+ + +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/LibrarianProfile.tsx b/client/src/features/admin/components/LibrarianProfile.tsx new file mode 100644 index 0000000..c62a49e --- /dev/null +++ b/client/src/features/admin/components/LibrarianProfile.tsx @@ -0,0 +1,154 @@ +import React, { useState } from "react"; +import { ArrowLeft, Mail, Phone, Calendar, ShieldCheck, Edit3, UserX } from "lucide-react"; +import { LibrarianModal } from "./LibrarianModal"; +import { DeleteLibrarianProfile } from "./DeleteLibrarianProfile"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { toast } from "sonner"; + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + created_at: string; + role: "READER" | "LIBRARIAN"; +} + +interface LibrarianProfileProps { + profile: UserRecord; + onBack: () => void; +} + +export const LibrarianProfile: React.FC = ({ profile, onBack }) => { + const queryClient = useQueryClient(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + // PURGE MUTATION EXECUTION LINK + const deleteMutation = useMutation({ + mutationFn: async () => { + const response = await axiosClient.delete(`/admin/librarian/${profile.user_id}`); + return response.data; + }, + onSuccess: () => { + toast.success("Librarian security profile wiped from core systems."); + queryClient.invalidateQueries({ queryKey: ["adminLibrariansMasterFeed"] }); + setIsDeleteModalOpen(false); + onBack(); // Step back to Hub Dashboard grid safely + }, + onError: () => { + toast.error("Failed to cleanly flush operator entry."); + setIsDeleteModalOpen(false); + }, + }); + + return ( + <> +
+ + {/* Navigation Action Strip */} + + + {/* Identity Details Box */} +
+ + {/* Banner Layout containing Upper Right Floating Action Options */} +
+ + System Node: Active + + + {/* ๐Ÿ’ก FLOATING ACTION MANAGEMENT SYSTEM PORTAL BUTTONS */} +
+ + +
+
+ +
+
+ {profile.name.slice(0, 2)} +
+ +
+
+

+ {profile.name} +

+

+ Assigned Library Officer +

+
+
+

Account ID

+

+ LIB-{profile.user_id.slice(-6).toUpperCase()} +

+
+
+ + {/* Matrix Data Grid Fields */} +
+
+ +
+ + {profile.gmail} +
+
+ +
+ +
+ + {profile.phone_number} +
+
+ +
+ +
+ + {new Date(profile.created_at).toLocaleDateString()} +
+
+
+ +
+
+
+ + {/* OVERLAY LAYERS MODAL PORTALS CONTAINER */} + setIsEditModalOpen(false)} + librarianToEdit={profile} + /> + + setIsDeleteModalOpen(false)} + onConfirm={() => deleteMutation.mutate()} + librarianName={profile.name} + isPending={deleteMutation.isPending} + /> + + ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/ManageLibrarians.tsx b/client/src/features/admin/components/ManageLibrarians.tsx new file mode 100644 index 0000000..d01aa82 --- /dev/null +++ b/client/src/features/admin/components/ManageLibrarians.tsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { useAuthStore } from "../../../store/authStore"; +import { LibrarianProfile } from "../components/LibrarianProfile"; +import { Mail, Phone, ArrowRight } from "lucide-react"; + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + created_at: string; + role: "READER" | "LIBRARIAN"; +} + +export const ManageLibrarians: React.FC = () => { + const token = useAuthStore((state) => state.token); + const [selectedLibrarianId, setSelectedLibrarianId] = useState(null); + + const { data: users = [], isLoading } = useQuery({ + queryKey: ["adminUsersMasterFeed", token], + queryFn: async () => { + const res = await axiosClient.get("/admin/librarians"); + return res.data?.data || res.data || []; + }, + enabled: !!token, + }); + + const librarians = users.filter((user) => user.role === "LIBRARIAN"); + + // Dynamic context profile drill-down layout viewport interceptor + if (selectedLibrarianId) { + const targetProfile = librarians.find((l) => l.user_id === selectedLibrarianId); + if (targetProfile) { + return ( + setSelectedLibrarianId(null)} + /> + ); + } + } + + return ( +
+ {isLoading ? ( +
+ Sifting team allocation profiles... +
+ ) : ( +
+ {librarians.length === 0 ? ( +
+ No registered operator accounts tracked on this network. +
+ ) : ( + librarians.map((librarian) => ( +
setSelectedLibrarianId(librarian.user_id)} + className="group bg-white p-5 rounded-2xl border border-slate-light/10 shadow-xs hover:shadow-md transition-all cursor-pointer flex flex-col justify-between" + > +
+
+
+ {librarian.name.slice(0, 2)} +
+ + LIBRARIAN-{librarian.user_id.slice(-4).toUpperCase()} + +
+ +

+ {librarian.name} +

+ +
+

{librarian.gmail}

+

{librarian.phone_number}

+
+
+ +
+ Profile + +
+
+ )) + )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/ManageUsers.tsx b/client/src/features/admin/components/ManageUsers.tsx new file mode 100644 index 0000000..1bdda3d --- /dev/null +++ b/client/src/features/admin/components/ManageUsers.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { useAuthStore } from "../../../store/authStore"; +import { Search, UserPlus } from "lucide-react"; +import { UserModal } from "./UserModal"; +import { UserDetailsModal } from "./UserDetailsModal"; // ๐Ÿ‘ˆ 1. IMPORT USER DETAILS VIEWER MODAL + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + created_at: string; + role: "READER" | "LIBRARIAN"; +} + +export const ManageUsers: React.FC = () => { + const token = useAuthStore((state) => state.token); + const [searchQuery, setSearchQuery] = useState(""); + + const [isModalOpen, setIsModalOpen] = useState(false); + + // ๐Ÿ’ก NEW STATE: Track explicitly selected row user profiles for focused details view + const [selectedUser, setSelectedUser] = useState(null); + + const { data: users = [], isLoading } = useQuery({ + queryKey: ["adminUsersMasterFeed", token], + queryFn: async () => { + const res = await axiosClient.get("/admin/readers"); + return res.data?.data || res.data || []; + }, + enabled: !!token, + }); + + const filteredReaders = users.filter((user) => + user.name.toLowerCase().includes(searchQuery.toLowerCase()) || + user.gmail.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+ {/* Control Filter Toolbar card row */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-canvas-dominant border border-slate-light/10 rounded-xl text-sm font-semibold focus:bg-white focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary outline-hidden transition-all" + /> +
+ + +
+ + {/* Structural Data Matrix Table Block */} + {isLoading ? ( +
+ Loading library account directory... +
+ ) : ( +
+
+ + + + + + + + + + + + {filteredReaders.length === 0 ? ( + + + + ) : ( + filteredReaders.map((user) => ( + // ๐Ÿ’ก MODIFIED: Added triggering click parameters and pointer cursor to table row elements + setSelectedUser(user)} + className="hover:bg-canvas-dominant/60 transition-colors select-none cursor-pointer" + > + + + + + + + )) + )} + +
System IDFull NameEmail AddressPhone ProfileEntry Timestamp
+ No matching user files found inside the database logs. +
+ USR-{user.user_id.slice(-4)} + {user.name}{user.gmail}{user.phone_number} + {new Date(user.created_at).toLocaleDateString()} +
+
+
+ )} + + {/* Add New User Overlay Portal Modal Frame */} + setIsModalOpen(false)} /> + + {/* ๐Ÿ’ก FIXED: Key prop forces component to clean-mount every time selectedUser changes, removing lint dependency loops completely */} + setSelectedUser(null)} + /> +
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/UserDetailsModal.tsx b/client/src/features/admin/components/UserDetailsModal.tsx new file mode 100644 index 0000000..e3bf900 --- /dev/null +++ b/client/src/features/admin/components/UserDetailsModal.tsx @@ -0,0 +1,294 @@ +import React, { useState } from "react"; +import { X, User, Mail, Phone, Calendar, Shield, Edit2, Trash2, Check, RotateCcw } from "lucide-react";// KeyRound +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { toast } from "sonner"; +import { AxiosError } from "axios"; +import { DeleteUserModal } from "./DeleteUserModal"; // ๐Ÿ’ก Import the new Modal + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + password?: string; // ๐Ÿ’ก Added password field structure support + created_at: string; + role: "READER" | "LIBRARIAN"; +} + +interface UserDetailsModalProps { + user: UserRecord | null; + onClose: () => void; +} + +interface BackendErrorResponse { + success: boolean; + message: string; +} + +export const UserDetailsModal: React.FC = ({ user, onClose }) => { + const queryClient = useQueryClient(); + const [isEditing, setIsEditing] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); // ๐Ÿ’ก Tracks delete modal portal + + const [name, setName] = useState(user?.name || ""); + const [gmail, setGmail] = useState(user?.gmail || ""); + const [phoneNumber, setPhoneNumber] = useState(user?.phone_number || ""); + const [password, setPassword] = useState(user?.password || ""); // ๐Ÿ’ก Holds raw admin state string + const [errors, setErrors] = useState>({}); + + // 1. UPDATE MUTATION + const updateMutation = useMutation({ + mutationFn: async (payload: Record) => { + const response = await axiosClient.patch(`/admin/user/${user?.user_id}`, payload); + return response.data; + }, + onSuccess: () => { + toast.success("User account information synchronized successfully."); + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + setIsEditing(false); + }, + onError: (error: AxiosError) => { + toast.error(error.response?.data?.message || "Failed to update record details."); + }, + }); + + // 2. DELETE MUTATION + const deleteMutation = useMutation({ + mutationFn: async () => { + const response = await axiosClient.delete(`/admin/user/${user?.user_id}`); + return response.data; + }, + onSuccess: () => { + toast.success("User file purged from system registry completely."); + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + setIsDeleteModalOpen(false); // Close portal layer + onClose(); // Close master detail file overlay + }, + onError: (error: AxiosError) => { + toast.error(error.response?.data?.message || "Purge instruction failed."); + setIsDeleteModalOpen(false); + }, + }); + + if (!user) return null; + + const validateForm = () => { + const localErrors: Record = {}; + if (!name.trim()) localErrors.name = "Full name"; + + const gmailRegex = /^[a-z0-9](\.?[a-z0-9]){4,29}@gmail\.com$/; + if (!gmail.trim()) localErrors.gmail = "Email address"; + else if (!gmailRegex.test(gmail.toLowerCase())) localErrors.gmail = "Must register a valid structured @gmail.com handle."; + + const phoneRegex = /^\d{10}$/; + if (!phoneNumber) localErrors.phoneNumber = "Phone Number"; + else if (!phoneRegex.test(phoneNumber)) localErrors.phoneNumber = "Must be a 10-digit numeric character lineup."; + + if (!password) { + localErrors.password = "Password"; + } else if (password.length < 6) { + localErrors.password = "Security strings must be at least 6 characters long."; + } + + setErrors(localErrors); + return Object.keys(localErrors).length === 0; + }; + + const handleUpdateSubmit = () => { + if (!validateForm()) return; + + updateMutation.mutate({ + name: name.trim(), + gmail: gmail.trim().toLowerCase(), + phone_number: phoneNumber, + password: password, // ๐Ÿ’ก Passing plain text directly for now (until we handle backend bcrypt hashing next) + }); + }; + + const handleRevert = () => { + setName(user.name); + setGmail(user.gmail); + setPhoneNumber(user.phone_number); + setPassword(user.password || ""); + setErrors({}); + setIsEditing(false); + }; + + return ( + <> +
+
+ + {/* Modal Top Banner Header */} +
+
+

+ {isEditing ? "Edit User Details" : "User Information"} +

+

+ UUID: USR-{user.user_id.slice(-6)} +

+
+ +
+ +
+ + {/* Full Name Entry Row */} +
+ +
+ + setName(e.target.value)} + className={`w-full pl-9 pr-4 py-2 rounded-xl text-xs font-bold transition-all outline-hidden ${ + isEditing + ? `bg-white border text-slate-secondary focus:ring-4 ${errors.name ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-sage-primary focus:ring-sage-primary/5"}` + : "bg-canvas-dominant/60 border border-transparent text-slate-secondary/80 cursor-not-allowed" + }`} + /> +
+ {errors.name &&

{errors.name}

} +
+ + {/* Gmail Address Entry Row */} +
+ +
+ + setGmail(e.target.value)} + className={`w-full pl-9 pr-4 py-2 rounded-xl text-xs font-bold transition-all outline-hidden ${ + isEditing + ? `bg-white border text-slate-secondary focus:ring-4 ${errors.gmail ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-sage-primary focus:ring-sage-primary/5"}` + : "bg-canvas-dominant/60 border border-transparent text-slate-secondary/80 cursor-not-allowed" + }`} + /> +
+ {errors.gmail &&

{errors.gmail}

} +
+ + {/* Phone Number Row */} +
+ +
+ + setPhoneNumber(e.target.value.replace(/\D/g, ""))} + className={`w-full pl-9 pr-4 py-2 rounded-xl text-xs font-bold transition-all outline-hidden ${ + isEditing + ? `bg-white border text-slate-secondary focus:ring-4 ${errors.phoneNumber ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-sage-primary focus:ring-sage-primary/5"}` + : "bg-canvas-dominant/60 border border-transparent text-slate-secondary/80 cursor-not-allowed" + }`} + /> +
+ {errors.phoneNumber &&

{errors.phoneNumber}

} +
+ + {/* ๐Ÿ’ก ADDED: Plain Text System Access Key Entry (Admin Eyes Only View) */} + {/*
+ +
+ + setPassword(e.target.value)} + placeholder="No active password configuration synced" + className={`w-full pl-9 pr-4 py-2 rounded-xl text-xs font-bold transition-all outline-hidden font-data tracking-wide ${ + isEditing + ? `bg-white border text-slate-secondary focus:ring-4 ${errors.password ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-sage-primary focus:ring-sage-primary/5"}` + : "bg-canvas-dominant/60 border border-transparent text-slate-secondary/60 cursor-not-allowed" + }`} + /> +
+ {errors.password &&

{errors.password}

} +
*/} + + {/* Immutable Static Metadata Columns */} +
+
+ Role +
+ + {user.role} +
+
+
+ System Enrollment Date +
+ + {new Date(user.created_at).toLocaleDateString()} +
+
+
+ + {/* Action Control Interface Tray Footers */} +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ +
+
+
+ + {/* ๐Ÿ’ก Secure Action Overlay Portal Portal Layer */} + setIsDeleteModalOpen(false)} + onConfirm={() => deleteMutation.mutate()} + userName={user.name} + isPending={deleteMutation.isPending} + /> + + ); +}; \ No newline at end of file diff --git a/client/src/features/admin/components/UserModal.tsx b/client/src/features/admin/components/UserModal.tsx new file mode 100644 index 0000000..a33b729 --- /dev/null +++ b/client/src/features/admin/components/UserModal.tsx @@ -0,0 +1,251 @@ +import React, { useState } from "react"; +import { X, User, Mail, Lock, Phone, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { axiosClient } from "../../../api/axiosClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +interface BackendErrorResponse { + success: boolean; + message: string; +} + +interface UserModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const UserModal: React.FC = ({ isOpen, onClose }) => { + const queryClient = useQueryClient(); + + // Form Fields State tracking parameters + const [name, setName] = useState(""); + const [gmail, setGmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + + // Error tracking vectors + const [errors, setErrors] = useState>({}); + + // React Query Mutation handler to execute the POST endpoint handshake + const addUserMutation = useMutation({ + mutationFn: async (payload: Record) => { + const response = await axiosClient.post("/admin/add-user", payload); + return response.data; + }, + onSuccess: () => { + toast.success("New library reader account provisioned successfully."); + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + handleResetAndClose(); + }, + onError: (error: AxiosError) => { + console.error("Account Creation Failed:", error); + const serverMessage = error.response?.data?.message; + toast.error(serverMessage || "Failed to finalize account registry."); + }, + }); + + const handleResetAndClose = () => { + setName(""); + setGmail(""); + setPassword(""); + setConfirmPassword(""); + setPhoneNumber(""); + setErrors({}); + onClose(); + }; + + const validateForm = () => { + const localErrors: Record = {}; + + if (!name.trim()) localErrors.name = "Full name validation entry is required."; + + const gmailRegex = /^[a-z0-9](\.?[a-z0-9]){4,29}@gmail\.com$/; + if (!gmail.trim()) { + localErrors.gmail = "Email address tracking parameters are required."; + } else if (!gmailRegex.test(gmail.toLowerCase())) { + localErrors.gmail = "Please supply a valid structured @gmail.com routing handle."; + } + + const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\W_]{8,}$/; + if (!password) { + localErrors.password = "Security credential string allocation is required."; + } else if (!passwordRegex.test(password)) { + localErrors.password = "Must contain 8+ characters, with uppercase, lowercase, and numeric parameters."; + } + + if (password !== confirmPassword) { + localErrors.confirmPassword = "Security confirmation mismatch. Verify security values match."; + } + + const phoneRegex = /^\d{10}$/; + if (!phoneNumber) { + localErrors.phoneNumber = "Phone connectivity baseline mapping is required."; + } else if (!phoneRegex.test(phoneNumber)) { + localErrors.phoneNumber = "Must register an absolute 10-digit numeric line string."; + } + + setErrors(localErrors); + return Object.keys(localErrors).length === 0; + }; + + const handleSubmission = (e: React.FormEvent) => { + e.preventDefault(); + if (!validateForm()) return; + + addUserMutation.mutate({ + name: name.trim(), + gmail: gmail.trim().toLowerCase(), + password, + phone_number: phoneNumber, + role: "READER", + }); + }; + + if (!isOpen) return null; + + return ( +
+
+ + {/* Header Layout block */} +
+
+

New Library Reader

+

All the below informations are mandatory to fill

+
+ +
+ + {/* Input Interactive layout fields */} +
+ + {/* Name Field */} +
+ +
+ + setName(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.name ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.name &&

{errors.name}

} +
+ + {/* Email Field */} +
+ +
+ + setGmail(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.gmail ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.gmail &&

{errors.gmail}

} +
+ + {/* Password Field */} +
+ +
+ + {/* ๐Ÿ’ก FIXED: Changed type to "text" and optimized spacing font details */} + setPassword(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold font-data tracking-wide transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.password ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.password &&

{errors.password}

} +
+ + {/* Reconfirm Password Field */} +
+ +
+ + {/* ๐Ÿ’ก FIXED: Changed type to "text" and optimized spacing font details */} + setConfirmPassword(e.target.value)} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold font-data tracking-wide transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.confirmPassword ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.confirmPassword &&

{errors.confirmPassword}

} +
+ + {/* Phone Number Field */} +
+ +
+ + setPhoneNumber(e.target.value.replace(/\D/g, ""))} + className={`w-full pl-9 pr-4 py-2 bg-canvas-dominant border text-slate-secondary rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.phoneNumber ? "border-utility-crimson focus:ring-utility-crimson/5" : "border-slate-light/10 focus:ring-sage-primary/5 focus:border-sage-primary" + }`} + /> +
+ {errors.phoneNumber &&

{errors.phoneNumber}

} +
+ + {/* Enforced Fixed Configuration Parameter Information Display */} +
+ + Role : READER +
+ + {/* Operational action execution buttons row */} +
+ + +
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/admin/pages/AdminPanel.tsx b/client/src/features/admin/pages/AdminPanel.tsx new file mode 100644 index 0000000..761951e --- /dev/null +++ b/client/src/features/admin/pages/AdminPanel.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Users, ShieldAlert, BookOpen } from "lucide-react"; + +export const AdminPanel: React.FC = () => { + + + return ( +
+ {/* Premium Welcome Banner Matrix card */} +
+

+ Welcome back, {"System Admin"} +

+

+ System access initialization verified. Use the main layout directory console to evaluate infrastructure configurations. +

+
+ + {/* Grid Display Metrics Blocks */} +
+
+
+ +
+
+

System Users

+

Active Directory

+
+
+ +
+
+ +
+
+

Librarians

+

Assigned Officers

+
+
+ +
+
+ +
+
+

Core Logs

+

Operational

+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/auth/pages/Login.tsx b/client/src/features/auth/pages/Login.tsx index cf858b3..62e315b 100644 --- a/client/src/features/auth/pages/Login.tsx +++ b/client/src/features/auth/pages/Login.tsx @@ -7,77 +7,84 @@ import { LoginSchema } from "../../../types/schemas"; import { toast } from "sonner"; import { motion } from "framer-motion"; +// ... keep existing imports unchanged + export const Login = () => { const navigate = useNavigate(); const setAuth = useAuthStore((state) => state.setAuth); - // Form Field Tracking Configuration states const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [selectedRole, setSelectedRole] = useState<"ADMIN" | "LIBRARIAN">("LIBRARIAN"); - // UI Presentation States const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({}); - const handleFormSubmission = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setFieldErrors({}); - - // 1. Client-Side Parsing via your frontend schema - const parsingResults = LoginSchema.safeParse({ - email, - password, - role: selectedRole, +const handleFormSubmission = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setFieldErrors({}); + + const parsingResults = LoginSchema.safeParse({ + email, + password, + role: selectedRole, + }); + + if (!parsingResults.success) { + const structuredErrors: { email?: string; password?: string } = {}; + parsingResults.error.issues.forEach((err) => { + if (err.path[0] === "email") structuredErrors.email = err.message; + if (err.path[0] === "password") structuredErrors.password = err.message; + }); + setFieldErrors(structuredErrors); + setIsLoading(false); + toast.error("Validation failed. Please address layout errors."); + return; + } + + try { + const networkResponse = await axiosClient.post("/auth/login", { + gmail: email, + password: password, + role: selectedRole, }); - if (!parsingResults.success) { - const structuredErrors: { email?: string; password?: string } = {}; - parsingResults.error.issues.forEach((err) => { - if (err.path[0] === "email") structuredErrors.email = err.message; - if (err.path[0] === "password") structuredErrors.password = err.message; - }); - setFieldErrors(structuredErrors); - setIsLoading(false); - toast.error("Validation failed. Please address layout errors."); + const targetPayload = networkResponse.data?.data || networkResponse.data; + const { user, token } = targetPayload; + + if (!token || !user) { + toast.error("Invalid token package structural layout returned from server."); return; } - // 2. Transmit Handshake request to the backend REST API - try { - const networkResponse = await axiosClient.post("/auth/login", { - gmail: email, - password: password, - role: selectedRole, - }); - - const targetPayload = networkResponse.data?.data || networkResponse.data; - const { user, token } = targetPayload; - - if (!token || !user) { - toast.error("Invalid token package structural layout returned from server."); - return; - } - + // Explicit Role Matching Verification Strategy + if (user.role === "ADMIN" && selectedRole === "ADMIN") { + setAuth(user, token); + toast.success("Admin Logged In Successfully"); + navigate("/admin/dashboard"); + } else if (user.role === "LIBRARIAN" && selectedRole === "LIBRARIAN") { setAuth(user, token); - toast.success("Login Successfully"); + toast.success("Librarian Logged In Successfully"); navigate("/dashboard"); - - } catch (error: unknown) { - console.error("Login Failed:", error); - - if (axios.isAxiosError(error)) { - toast.error(error.response?.data?.message || "Invalid account credentials."); - } else { - toast.error("An unexpected infrastructure error occurred."); - } - } finally { - setIsLoading(false); + } else { + // Security fallback: if selected role tab does not perfectly match database configuration role + toast.error("Access Denied: Role mismatch error. Redirecting..."); + navigate("/login"); } - }; + } catch (error: unknown) { + console.error("Login Failed:", error); + if (axios.isAxiosError(error)) { + toast.error(error.response?.data?.message || "Invalid account credentials."); + } else { + toast.error("An unexpected infrastructure error occurred."); + } + } finally { + setIsLoading(false); + } +}; return (
{/* Decorative Branding Background Blobs */} diff --git a/client/src/index.css b/client/src/index.css index a261c24..80d1d88 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,8 +1,10 @@ -@import "tailwindcss"; - /* ๐Ÿ”ค Google Fonts Integration: Inter & Roboto */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Roboto:wght@400;500;700&display=swap'); + +@import "tailwindcss"; + + @theme { /* ๐ŸŽจ Core Sage & Slate Configuration Matrix */ --color-sage-primary: #27AE60; /* 10% Accent Call-to-Actions */ diff --git a/client/src/routes/AppRoutes.tsx b/client/src/routes/AppRoutes.tsx index 30fc9b8..ce89dfe 100644 --- a/client/src/routes/AppRoutes.tsx +++ b/client/src/routes/AppRoutes.tsx @@ -11,6 +11,12 @@ import { FinesPage } from "../features/fines/pages/FinesPage"; import { ReturnedBooks } from "../features/returnedbooks/pages/ReturnedBooks"; import { ManageCategories } from "../features/categories/pages/ManageCategories"; +// Admin Module Component Import Declarations +import { AdminLayout } from "../features/admin/components/AdminLayout"; +import { AdminPanel } from "../features/admin/pages/AdminPanel"; +import { ManageUsers } from "../features/admin/components/ManageUsers"; +import { ManageLibrarians } from "../features/admin/components/ManageLibrarians"; + export const AppRoutes = () => { return ( @@ -22,18 +28,31 @@ export const AppRoutes = () => { {/* Shielded Secure Corporate Environment Boundary */} }> + + {/* 1. Librarian Operations Workspace Subsystem Layout Group */} }> } /> - } /> - } /> - } /> - } /> - } /> - }> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* 2. Isolated High-Clearance Admin Subsystem Layout Group */} + {/* Using clear absolute path routing mappings to avoid ambiguity */} + }> + } /> + } /> + } /> + } /> + - } /> + {/* ๐Ÿ’ก FIXED: General Catch-All Global Redirection directs straight to login */} + } /> ); diff --git a/client/src/routes/ProtectedGuard.tsx b/client/src/routes/ProtectedGuard.tsx index fe2ea64..1f8cb04 100644 --- a/client/src/routes/ProtectedGuard.tsx +++ b/client/src/routes/ProtectedGuard.tsx @@ -1,8 +1,26 @@ -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; import { useAuthStore } from "../store/authStore"; export const ProtectedGuard = () => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - - return isAuthenticated ? : ; + const user = useAuthStore((state) => state.user); + const location = useLocation(); + + // 1. If not logged in at all, boot them to login console + if (!isAuthenticated) { + return ; + } + + // 2. Strict URL Space Boundary Enforcement + // Prevent Librarians from entering the "/admin" namespace + if (location.pathname.startsWith("/admin") && user?.role !== "ADMIN") { + return ; + } + + // Prevent Admins from entering librarian dashboard paths + if (!location.pathname.startsWith("/admin") && user?.role === "ADMIN") { + return ; + } + + return ; }; \ No newline at end of file diff --git a/client/src/routes/PublicGuard.tsx b/client/src/routes/PublicGuard.tsx index 5683bfe..698e8d7 100644 --- a/client/src/routes/PublicGuard.tsx +++ b/client/src/routes/PublicGuard.tsx @@ -3,6 +3,17 @@ import { useAuthStore } from "../store/authStore"; export const PublicGuard = () => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - - return !isAuthenticated ? : ; + const user = useAuthStore((state) => state.user); + + // If NOT logged in, let them view public pages (like /login) + if (!isAuthenticated) { + return ; + } + + // ๐Ÿ’ก FIXED: Dynamic RBAC deflection for logged-in users trying to access /login + if (user?.role === "ADMIN") { + return ; + } + + return ; }; \ No newline at end of file diff --git a/server/src/modules/admin/admin.controller.ts b/server/src/modules/admin/admin.controller.ts new file mode 100644 index 0000000..d057982 --- /dev/null +++ b/server/src/modules/admin/admin.controller.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from 'express'; +import { AdminService } from '../admin/admin.service.js'; + +export class AdminController { + private adminService = new AdminService(); + + // GET /admin/readers + getReaders = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const data = await this.adminService.getReadersFeed(); + res.status(200).json({ + success: true, + message: "Readers directory compiled successfully.", + data, + }); + } catch (error) { + next(error); + } + }; + + // GET /admin/librarians + getLibrarians = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const data = await this.adminService.getLibrariansFeed(); + res.status(200).json({ + success: true, + message: "Librarians directory compiled successfully.", + data, + }); + } catch (error) { + next(error); + } + }; + + // POST /admin/add-user + addUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { user_id, name, gmail, password, phone_number, role } = req.body; + + const newReader = await this.adminService.createUser({ + user_id: user_id || "", // ๐Ÿ’ก Fixed: Explicitly fulfills the required property check + name, + gmail, + password, + phone_number, + role: role || 'READER' + }); + + res.status(201).json({ + success: true, + message: "New user account provisioned successfully.", + data: newReader, + }); + } catch (error) { + next(error); + } + }; + + // PATCH /admin/user/:user_id + updateUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { user_id } = req.params; + const { name, gmail, password, phone_number } = req.body; + + // ๐Ÿ’ก Fixed: Coerces the parameter string fallback to satisfy the compiler + await this.adminService.updateUser(String(user_id || ""), { + name, + gmail, + password, + phone_number, + }); + + res.status(200).json({ + success: true, + message: "User context metrics synchronized successfully.", + }); + } catch (error) { + next(error); + } + }; + + // DELETE /admin/user/:user_id + deleteUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { user_id } = req.params; + + // ๐Ÿ’ก Fixed: Coerces the parameter string fallback to satisfy the compiler + await this.adminService.deleteUser(String(user_id || "")); + + res.status(200).json({ + success: true, + message: "User account file purged from registry matrix completely.", + }); + } catch (error) { + next(error); + } + }; +} \ No newline at end of file diff --git a/server/src/modules/admin/admin.repository.ts b/server/src/modules/admin/admin.repository.ts new file mode 100644 index 0000000..13af671 --- /dev/null +++ b/server/src/modules/admin/admin.repository.ts @@ -0,0 +1,33 @@ +import User from '../../database/models/User.js'; // Adjust path to your Sequelize User model +import { UserAttributes } from '../admin/admin.types.js'; + +export class AdminRepository { + // 1. Queries users matching a specific role profile cleanly + async findUsersByRole(role: 'READER' | 'LIBRARIAN'): Promise { + return await User.findAll({ + where: { role }, + attributes: ['uuid', 'name', 'gmail', 'phone_number', 'role', 'created_at'], + order: [['created_at', 'DESC']], + }); + } + + async createUserRepository( + data: Omit & { password?: string } + ): Promise { + return await User.create(data as any); + } + + // 3. Update existing user details (Matches against your 'uuid' column handle) + async updateUserRepository(uuid: string, updateData: Partial>): Promise<[number]> { + return await User.update(updateData, { + where: { uuid } + }); + } + + // 4. Force Purge / Delete user execution vector from core logs entirely + async deleteUserRepository(uuid: string): Promise { + return await User.destroy({ + where: { uuid } + }); + } +} \ No newline at end of file diff --git a/server/src/modules/admin/admin.routes.ts b/server/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..034a844 --- /dev/null +++ b/server/src/modules/admin/admin.routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import validate from '../../middlewares/validate.js'; +import { addUserValidation, updateUserValidation, deleteUserValidation } from './admin.validation.js'; +import { AdminController } from '../admin/admin.controller.js'; +// import { protect, restrictTo } from '../middlewares/authMiddleware'; +// ^ Add your custom RBAC middlewares here to ensure only ADMIN tokens bypass this route! + +const router = Router(); +const controller = new AdminController(); + +// Endpoints for readers +router.get('/readers', controller.getReaders); +router.post('/add-user', validate(addUserValidation), controller.addUser); +router.patch('/user/:user_id', validate(updateUserValidation), controller.updateUser); +router.delete('/user/:user_id', validate(deleteUserValidation), controller.deleteUser); + +//Endpoints for librarins +router.get('/librarians', controller.getLibrarians); + +export default router; \ No newline at end of file diff --git a/server/src/modules/admin/admin.service.ts b/server/src/modules/admin/admin.service.ts new file mode 100644 index 0000000..fe96942 --- /dev/null +++ b/server/src/modules/admin/admin.service.ts @@ -0,0 +1,86 @@ +import { AdminRepository } from '../admin/admin.repository.js'; +import { AdminUserResponseDto, UserAttributes } from '../admin/admin.types.js'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; + +export class AdminService { + private adminRepository = new AdminRepository(); + + // 1. Compile clean Reader account profile records + async getReadersFeed(): Promise { + const readers = await this.adminRepository.findUsersByRole('READER'); + return readers.map(user => ({ + user_id: user.uuid, + name: user.name, + gmail: user.gmail, + phone_number: user.phone_number, + role: user.role, + created_at: user.created_at + })); + } + + // 2. Compile clean Librarian account profile records + async getLibrariansFeed(): Promise { + const librarians = await this.adminRepository.findUsersByRole('LIBRARIAN'); + return librarians.map(user => ({ + user_id: user.uuid, + name: user.name, + gmail: user.gmail, + phone_number: user.phone_number, + role: user.role, + created_at: user.created_at + })); + } + + // 3. Execute new account creation business logic + // ๐Ÿ’ก FIXED: The input payload strips the timestamps and UUID, but we add it to the final tracking object safely + async createUser( + payload: Omit & { password?: string } +): Promise { + + const generatedUuid = crypto.randomUUID(); + let finalizedPassword = payload.password; + + // ๐Ÿ’ก Check if a password exists in the payload, then safely encrypt it + if (finalizedPassword) { + const saltRounds = 10; + finalizedPassword = await bcrypt.hash(finalizedPassword, saltRounds); + } + + // Construct the full object with the generated UUID and securely hashed credentials + const userCreationData = { + ...payload, + uuid: generatedUuid, + ...(finalizedPassword && { password: finalizedPassword }), // Replaces raw string with secure bcrypt variant + }; + + const newUserRecord = await this.adminRepository.createUserRepository(userCreationData); + + return { + user_id: newUserRecord.uuid, + name: newUserRecord.name, + gmail: newUserRecord.gmail, + phone_number: newUserRecord.phone_number, + role: newUserRecord.role, + created_at: newUserRecord.created_at, + }; + } + + // 4. Update structural details for existing directory rows + async updateUser(user_id: string, updateData: Partial> & { password?: string }): Promise { + const [affectedCount] = await this.adminRepository.updateUserRepository(user_id, updateData); + + if (affectedCount === 0) { + throw new Error("Target account profile was not tracked on this cluster matrix."); + } + } + + // 5. Purge and remove accounts entirely from active database shards + async deleteUser(user_id: string): Promise { + const deletedCount = await this.adminRepository.deleteUserRepository(user_id); + + if (deletedCount === 0) { + throw new Error("Target account profile was not tracked on this cluster matrix."); + } + } +} \ No newline at end of file diff --git a/server/src/modules/admin/admin.types.ts b/server/src/modules/admin/admin.types.ts new file mode 100644 index 0000000..63c7856 --- /dev/null +++ b/server/src/modules/admin/admin.types.ts @@ -0,0 +1,19 @@ +export interface UserAttributes { + user_id: string; + name: string; + gmail: string; + phone_number: string | null; + role: 'READER' | 'LIBRARIAN' | 'ADMIN'; + created_at: Date; + updated_at: Date; +} + +// Dedicated output DTO layout shapes for response clean frames +export interface AdminUserResponseDto { + user_id: string; + name: string; + gmail: string; + phone_number: string | null; + created_at: Date; + role: string; +} \ No newline at end of file diff --git a/server/src/modules/admin/admin.validation.ts b/server/src/modules/admin/admin.validation.ts new file mode 100644 index 0000000..df016c4 --- /dev/null +++ b/server/src/modules/admin/admin.validation.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +// ========================================== +// 1. SHARED BASE PATTERNS +// ========================================== +const nameSchema = z + .string({ error: "Full name is required." }) // ๐Ÿ’ก Fixed: Changed to error object format + .trim() + .min(1, { message: "Name parameter cannot be left blank." }); + +const gmailSchema = z + .string({ error: "Gmail address handle is required." }) // ๐Ÿ’ก Fixed: Changed to error object format + .trim() + .toLowerCase() + .regex(/^[a-z0-9](\.?[a-z0-9]){4,29}@gmail\.com$/, { + message: "Value must register a valid structural @gmail.com electronic layout.", + }); + +const passwordSchema = z + .string({ error: "Security credential string is required." }) // ๐Ÿ’ก Fixed: Changed to error object format + .min(8, { message: "Security value must be a minimum of 8 characters long." }) + .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\W_]{8,}$/, { + message: "Security key must contain an uppercase letter, lowercase letter, and a numeric value.", + }); + +const phoneSchema = z + .string({ error: "Phone connectivity baseline mapping is required." }) // ๐Ÿ’ก Fixed: Changed to error object format + .trim() + .regex(/^\d{10}$/, { message: "Phone profile must be an absolute 10-digit numeric line string." }); + + +// ========================================== +// 2. EXPORTED MIDDLEWARE SCHEMAS +// ========================================== + +// POST /admin/add-user +export const addUserValidation = z.object({ + body: z.object({ + name: nameSchema, + gmail: gmailSchema, + password: passwordSchema, + phone_number: phoneSchema, + // ๐Ÿ’ก FIXED: Enforced your specific error mapping format directly onto the enum declaration + role: z.enum(["READER", "LIBRARIAN"], { + error: "Role value allocation must be READER or LIBRARIAN.", + }).default("READER"), + }), +}); + +// PATCH /admin/user/:user_id +export const updateUserValidation = z.object({ + params: z.object({ + user_id: z.string().uuid({ message: "Invalid user ID URL route key format." }), + }), + body: z.object({ + name: nameSchema.optional(), + gmail: gmailSchema.optional(), + password: passwordSchema.optional(), + phone_number: phoneSchema.optional(), + }).strict(), +}); + +// DELETE /admin/user/:user_id +export const deleteUserValidation = z.object({ + params: z.object({ + user_id: z.string().uuid({ message: "Invalid user ID URL route key format." }), + }), +}); \ No newline at end of file diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 9f04516..14ee1e0 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,12 +1,13 @@ import { Router } from "express"; import authRoutes from "../modules/auth/auth.routes.js"; import memberRoutes from "../modules/members/member.routes.js"; -import bookRoutes from "../modules/books/book.routes.js" -import categoriesRoutes from "../modules/categories/categories.routes.js" +import bookRoutes from "../modules/books/book.routes.js"; +import categoriesRoutes from "../modules/categories/categories.routes.js"; import issueRoutes from "../modules/issues/issue.routes.js"; -import fineRoutes from "../modules/fines/fine.routes.js" +import fineRoutes from "../modules/fines/fine.routes.js"; import dashboardRoutes from "../modules/dashboard/dashboard.routes.js"; -import azureRoutes from "../modules/azureAI/aiScanner.routes.js" +import azureRoutes from "../modules/azureAI/aiScanner.routes.js"; +import adminRoutes from "../modules/admin/admin.routes.js"; const router = Router(); @@ -18,5 +19,6 @@ router.use("/issues", issueRoutes); router.use("/fines", fineRoutes); router.use("/dashboard", dashboardRoutes); router.use("/ai",azureRoutes); +router.use("/admin",adminRoutes) export default router; \ No newline at end of file From c27f42d8748487e99936d56d3d139f20052eba35 Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Wed, 10 Jun 2026 18:40:45 +0530 Subject: [PATCH 2/4] feat(admin): implement admin panel, improve UI, and strengthen backend reliability with 89 unit tests --- .../admin/components/LibrarianModal.tsx | 61 +- .../admin/components/LibrarianProfile.tsx | 13 +- .../admin/components/ManageLibrarians.tsx | 210 +++-- .../features/admin/components/ManageUsers.tsx | 121 ++- .../src/features/admin/pages/AdminPanel.tsx | 105 ++- .../fines/components/DeleteFinesModal.tsx | 63 +- .../fines/components/FineDetailsModal.tsx | 118 +-- .../components/FinesNotificationBanner.tsx | 23 +- .../fines/components/RestoreFineModal.tsx | 28 +- .../components/SettleFinePaymentModal.tsx | 78 +- client/src/features/fines/pages/FinesPage.tsx | 175 ++-- .../components/DeleteTransactionModal.tsx | 70 +- .../issues/components/IssueDetailsModal.tsx | 138 ++-- .../issues/components/TransactionModal.tsx | 195 +++-- .../components/UnpaidFineAlertModal.tsx | 56 +- .../issues/pages/TransactionsPage.tsx | 184 +++-- .../components/DeleteConfirmationModal.tsx | 36 +- .../members/components/MemberDetailsModal.tsx | 87 +- .../members/components/MemberModal.tsx | 83 +- .../features/members/pages/MembersPage.tsx | 123 +-- .../components/DeletePlanModal.tsx | 94 +++ .../components/PlanDetailsModal.tsx | 121 +++ .../membershipPlans/components/PlanModal.tsx | 171 ++++ .../membershipPlans/pages/ManagePlan.tsx | 268 +++++++ .../components/ConfirmationModal.tsx | 54 +- .../components/ReturnedDetailsModal.tsx | 76 +- .../returnedbooks/pages/ReturnedBooks.tsx | 133 ++-- client/src/layouts/DashboardLayout.tsx | 139 ++-- client/src/pages/Dashboard.tsx | 58 +- client/src/routes/AppRoutes.tsx | 2 + server/src/modules/admin/admin.controller.ts | 141 +++- server/src/modules/admin/admin.repository.ts | 65 +- server/src/modules/admin/admin.routes.ts | 40 +- server/src/modules/admin/admin.service.ts | 183 ++++- server/src/modules/admin/admin.spec.ts | 514 ++++++++++++ server/src/modules/admin/admin.validation.ts | 50 +- server/src/modules/auth/auth.spec.ts | 234 +++--- server/src/modules/books/book.spec.ts | 427 +++++++--- .../src/modules/categories/categories.spec.ts | 421 ++++++++++ .../src/modules/dashboard/dashboard.spec.ts | 352 +++++---- server/src/modules/fines/fine.spec.ts | 428 ++++++++-- server/src/modules/issues/issue.spec.ts | 747 ++++++++++-------- server/src/modules/plans/plans.controller.ts | 67 ++ server/src/modules/plans/plans.repository.ts | 40 + server/src/modules/plans/plans.routes.ts | 15 + server/src/modules/plans/plans.service.ts | 63 ++ server/src/modules/plans/plans.types.ts | 14 + server/src/modules/plans/plans.validation.ts | 47 ++ server/src/routes/index.ts | 2 + 49 files changed, 5200 insertions(+), 1733 deletions(-) create mode 100644 client/src/features/membershipPlans/components/DeletePlanModal.tsx create mode 100644 client/src/features/membershipPlans/components/PlanDetailsModal.tsx create mode 100644 client/src/features/membershipPlans/components/PlanModal.tsx create mode 100644 client/src/features/membershipPlans/pages/ManagePlan.tsx create mode 100644 server/src/modules/admin/admin.spec.ts create mode 100644 server/src/modules/categories/categories.spec.ts create mode 100644 server/src/modules/plans/plans.controller.ts create mode 100644 server/src/modules/plans/plans.repository.ts create mode 100644 server/src/modules/plans/plans.routes.ts create mode 100644 server/src/modules/plans/plans.service.ts create mode 100644 server/src/modules/plans/plans.types.ts create mode 100644 server/src/modules/plans/plans.validation.ts diff --git a/client/src/features/admin/components/LibrarianModal.tsx b/client/src/features/admin/components/LibrarianModal.tsx index b32c061..9e28f4b 100644 --- a/client/src/features/admin/components/LibrarianModal.tsx +++ b/client/src/features/admin/components/LibrarianModal.tsx @@ -30,32 +30,23 @@ export const LibrarianModal: React.FC = ({ isOpen, onClose, const queryClient = useQueryClient(); const isEditMode = !!librarianToEdit; - // ๐Ÿ’ก INITIAL STATES (Derived safely from props) - const [name, setName] = useState(librarianToEdit?.name || ""); + const [name, setName] = useState(librarianToEdit?.name || ""); const [gmail, setGmail] = useState(librarianToEdit?.gmail || ""); - const [password, setPassword] = useState(librarianToEdit?.password || ""); + const [password, setPassword] = useState(""); const [phoneNumber, setPhoneNumber] = useState(librarianToEdit?.phone_number || ""); const [errors, setErrors] = useState>({}); - // ๐Ÿ’ก CLEAN FIX: handleResetAndClose now safely manages state resets directly const handleResetAndClose = () => { - if (!isEditMode) { - setName(""); - setGmail(""); - setPassword(""); - setPhoneNumber(""); - } else { - // Revert fields back to original prop values if the admin cancels editing - setName(librarianToEdit?.name || ""); - setGmail(librarianToEdit?.gmail || ""); - setPassword(librarianToEdit?.password || ""); - setPhoneNumber(librarianToEdit?.phone_number || ""); - } + setName(""); + setGmail(""); + setPassword(""); + setPhoneNumber(""); setErrors({}); onClose(); }; - // MUTATION HANDLER + // Mutation Handler +// Mutation Handler inside LibrarianModal.tsx const librarianMutation = useMutation({ mutationFn: async (payload: Record) => { if (isEditMode) { @@ -69,10 +60,13 @@ export const LibrarianModal: React.FC = ({ isOpen, onClose, onSuccess: () => { toast.success( isEditMode - ? "Officer metrics updated successfully." - : "New administrative terminal clearance provisioned." + ? "Librarian details updated successfully." + : "New librarian added successfully" ); - queryClient.invalidateQueries({ queryKey: ["adminLibrariansMasterFeed"] }); + + // ๐Ÿ’ก FIXED: This now matches your exact queryKey prefix to force an automatic background re-fetch! + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + handleResetAndClose(); }, onError: (error: AxiosError) => { @@ -114,12 +108,19 @@ export const LibrarianModal: React.FC = ({ isOpen, onClose, e.preventDefault(); if (!validateForm()) return; - const payload: Record = { - name: name.trim(), - gmail: gmail.trim().toLowerCase(), - phone_number: phoneNumber, - role: "LIBRARIAN", - }; + // ๐Ÿ’ก FIXED: Conditionally builds payload so 'role' is omitted in edit mode to satisfy .strict() Zod rules + const payload: Record = isEditMode + ? { + name: name.trim(), + gmail: gmail.trim().toLowerCase(), + phone_number: phoneNumber, + } + : { + name: name.trim(), + gmail: gmail.trim().toLowerCase(), + phone_number: phoneNumber, + role: "LIBRARIAN", + }; if (password) payload.password = password; @@ -186,7 +187,7 @@ export const LibrarianModal: React.FC = ({ isOpen, onClose, {errors.gmail &&

{errors.gmail}

}
- {/* Plain Text Password Field */} + {/* Password Field */}
- {/* Automated System Configuration Alert Badge */} + {/* Configuration Alert Badge */}
Enforced Authority Level : LIBRARIAN
- {/* Trigger Actions Button Row */} + {/* Action Buttons */}
diff --git a/client/src/features/admin/components/LibrarianProfile.tsx b/client/src/features/admin/components/LibrarianProfile.tsx index c62a49e..8d49320 100644 --- a/client/src/features/admin/components/LibrarianProfile.tsx +++ b/client/src/features/admin/components/LibrarianProfile.tsx @@ -32,10 +32,13 @@ export const LibrarianProfile: React.FC = ({ profile, onB return response.data; }, onSuccess: () => { - toast.success("Librarian security profile wiped from core systems."); - queryClient.invalidateQueries({ queryKey: ["adminLibrariansMasterFeed"] }); + toast.success("Librarian profile deleted successfully."); + + // ๐Ÿ’ก FIXED: Matches the exact tracking feed array cache tag key used in ManageLibrarians + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + setIsDeleteModalOpen(false); - onBack(); // Step back to Hub Dashboard grid safely + onBack(); }, onError: () => { toast.error("Failed to cleanly flush operator entry."); @@ -65,7 +68,7 @@ export const LibrarianProfile: React.FC = ({ profile, onB System Node: Active - {/* ๐Ÿ’ก FLOATING ACTION MANAGEMENT SYSTEM PORTAL BUTTONS */} + {/* FLOATING ACTION MANAGEMENT SYSTEM PORTAL BUTTONS */}
{/* OVERLAY LAYERS MODAL PORTALS CONTAINER */} + {/* ๐Ÿ’ก FIXED: Key binding ensures pristine input components mount when switching open states */} setIsEditModalOpen(false)} librarianToEdit={profile} diff --git a/client/src/features/admin/components/ManageLibrarians.tsx b/client/src/features/admin/components/ManageLibrarians.tsx index d01aa82..94b2203 100644 --- a/client/src/features/admin/components/ManageLibrarians.tsx +++ b/client/src/features/admin/components/ManageLibrarians.tsx @@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; import { useAuthStore } from "../../../store/authStore"; import { LibrarianProfile } from "../components/LibrarianProfile"; -import { Mail, Phone, ArrowRight } from "lucide-react"; +import { LibrarianModal } from "../components/LibrarianModal"; +import { Mail, Phone, ArrowRight, UserPlus, ChevronLeft, ChevronRight } from "lucide-react"; interface UserRecord { user_id: string; @@ -14,28 +15,64 @@ interface UserRecord { role: "READER" | "LIBRARIAN"; } +interface PaginatedLibrarianResponse { + data: UserRecord[]; + totalCount: number; +} + +interface ServerApiResponse { + success: boolean; + message: string; + data: PaginatedLibrarianResponse | UserRecord[]; +} + export const ManageLibrarians: React.FC = () => { const token = useAuthStore((state) => state.token); const [selectedLibrarianId, setSelectedLibrarianId] = useState(null); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const { data: users = [], isLoading } = useQuery({ - queryKey: ["adminUsersMasterFeed", token], + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + const { data, isLoading } = useQuery({ + queryKey: ["adminUsersMasterFeed", token, currentPage], queryFn: async () => { - const res = await axiosClient.get("/admin/librarians"); - return res.data?.data || res.data || []; + const offset = (currentPage - 1) * itemsPerPage; + const res = await axiosClient.get("/admin/librarians", { + params: { + limit: itemsPerPage, + offset: offset, + } + }); + return res.data; }, enabled: !!token, }); - const librarians = users.filter((user) => user.role === "LIBRARIAN"); + const responsePayload = data?.data; + + const librariansList: UserRecord[] = Array.isArray(data) + ? data + : Array.isArray(responsePayload) + ? responsePayload + : (responsePayload && "data" in responsePayload && Array.isArray(responsePayload.data)) + ? responsePayload.data + : []; + + const totalCount: number = Array.isArray(data) + ? librariansList.length + : (responsePayload && "totalCount" in responsePayload) + ? responsePayload.totalCount + : 0; + + const totalPages = Math.max(1, Math.ceil(totalCount / itemsPerPage)); - // Dynamic context profile drill-down layout viewport interceptor if (selectedLibrarianId) { - const targetProfile = librarians.find((l) => l.user_id === selectedLibrarianId); - if (targetProfile) { + const liveTargetProfile = librariansList.find((l: UserRecord) => l.user_id === selectedLibrarianId); + if (liveTargetProfile) { return ( setSelectedLibrarianId(null)} /> ); @@ -43,53 +80,132 @@ export const ManageLibrarians: React.FC = () => { } return ( -
+ // ๐Ÿ’ก CHANGED: Set structural flex container layout to calculate structural heights accurately +
+ + {/* HEADER CONTROLS (STAYS AT THE TOP) */} +
+
+

Library Operators

+

Manage staff terminals, clearance logs, and authority configurations.

+
+ +
+ {isLoading ? ( -
+
Sifting team allocation profiles...
) : ( -
- {librarians.length === 0 ? ( -
- No registered operator accounts tracked on this network. -
- ) : ( - librarians.map((librarian) => ( -
setSelectedLibrarianId(librarian.user_id)} - className="group bg-white p-5 rounded-2xl border border-slate-light/10 shadow-xs hover:shadow-md transition-all cursor-pointer flex flex-col justify-between" - > -
-
-
- {librarian.name.slice(0, 2)} + // ๐Ÿ’ก CHANGED: Nested contents now wrap into an explicit layout flow architecture +
+ + {/* SCROLLABLE GRID BOX AREA */} +
+
+ {librariansList.length === 0 ? ( +
+ No registered operator accounts tracked on this network. +
+ ) : ( + librariansList.map((librarian: UserRecord) => ( +
setSelectedLibrarianId(librarian.user_id)} + className="group bg-white p-5 rounded-2xl border border-slate-light/10 shadow-xs hover:shadow-md transition-all cursor-pointer flex flex-col justify-between" + > +
+
+
+ {librarian.name.slice(0, 2)} +
+ + LIBRARIAN-{librarian.user_id.slice(-4).toUpperCase()} + +
+ +

+ {librarian.name} +

+ +
+

{librarian.gmail}

+

{librarian.phone_number}

+
- - LIBRARIAN-{librarian.user_id.slice(-4).toUpperCase()} - -
-

- {librarian.name} -

- -
-

{librarian.gmail}

-

{librarian.phone_number}

+
+ Profile + +
-
+ )) + )} +
+
+ + {/* ๐Ÿ’ก FIXED: STICKY LOCKED BOTTOM PAGINATION CONTROLS BAR */} +
+
+ Showing {librariansList.length} of{" "} + {totalCount} operators logged +
+ +
+ + + {Array.from({ length: totalPages }, (_, idx) => idx + 1).map((pageNum) => ( + + ))} + + +
+
-
- Profile - -
-
- )) - )}
)} + + { + setIsCreateModalOpen(false); + setSelectedLibrarianId(null); + }} + librarianToEdit={librariansList.find((l: UserRecord) => l.user_id === selectedLibrarianId) || null} + />
); }; \ No newline at end of file diff --git a/client/src/features/admin/components/ManageUsers.tsx b/client/src/features/admin/components/ManageUsers.tsx index 1bdda3d..6396d63 100644 --- a/client/src/features/admin/components/ManageUsers.tsx +++ b/client/src/features/admin/components/ManageUsers.tsx @@ -2,9 +2,9 @@ import React, { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; import { useAuthStore } from "../../../store/authStore"; -import { Search, UserPlus } from "lucide-react"; +import { Search, UserPlus, ChevronLeft, ChevronRight } from "lucide-react"; import { UserModal } from "./UserModal"; -import { UserDetailsModal } from "./UserDetailsModal"; // ๐Ÿ‘ˆ 1. IMPORT USER DETAILS VIEWER MODAL +import { UserDetailsModal } from "./UserDetailsModal"; interface UserRecord { user_id: string; @@ -15,32 +15,73 @@ interface UserRecord { role: "READER" | "LIBRARIAN"; } +interface PaginatedUserResponse { + data: UserRecord[]; + totalCount: number; +} + +// ๐Ÿ’ก NEW TYPE: Defines the Axios wrapper shape from your Express server +interface ServerApiResponse { + success: boolean; + message: string; + data: PaginatedUserResponse | UserRecord[]; +} + export const ManageUsers: React.FC = () => { const token = useAuthStore((state) => state.token); const [searchQuery, setSearchQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + const [isModalOpen, setIsModalOpen] = useState(false); - - // ๐Ÿ’ก NEW STATE: Track explicitly selected row user profiles for focused details view const [selectedUser, setSelectedUser] = useState(null); - const { data: users = [], isLoading } = useQuery({ - queryKey: ["adminUsersMasterFeed", token], + // ๐Ÿ’ก FIXED: Configured hook typing explicitly + const { data, isLoading } = useQuery({ + queryKey: ["adminUsersMasterFeed", token, currentPage, searchQuery], queryFn: async () => { - const res = await axiosClient.get("/admin/readers"); - return res.data?.data || res.data || []; + const offset = (currentPage - 1) * itemsPerPage; + + const res = await axiosClient.get("/admin/readers", { + params: { + limit: itemsPerPage, + offset: offset, + search: searchQuery || undefined + } + }); + + return res.data; }, enabled: !!token, }); - const filteredReaders = users.filter((user) => - user.name.toLowerCase().includes(searchQuery.toLowerCase()) || - user.gmail.toLowerCase().includes(searchQuery.toLowerCase()) - ); + // ๐Ÿ’ก FIXED: Extracted nested fields cleanly without utilizing explicit 'any' hooks + const responsePayload = data?.data; + + const usersList: UserRecord[] = Array.isArray(data) + ? data + : Array.isArray(responsePayload) + ? responsePayload + : (responsePayload && "data" in responsePayload && Array.isArray(responsePayload.data)) + ? responsePayload.data + : []; + + const totalCount: number = Array.isArray(data) + ? usersList.length + : (responsePayload && "totalCount" in responsePayload) + ? responsePayload.totalCount + : 0; + + const totalPages = Math.max(1, Math.ceil(totalCount / itemsPerPage)); + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setCurrentPage(1); + }; return ( -
- {/* Control Filter Toolbar card row */} +
@@ -48,7 +89,7 @@ export const ManageUsers: React.FC = () => { type="text" placeholder="Search users by name or email database..." value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={handleSearchChange} className="w-full pl-10 pr-4 py-2 bg-canvas-dominant border border-slate-light/10 rounded-xl text-sm font-semibold focus:bg-white focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary outline-hidden transition-all" />
@@ -61,7 +102,6 @@ export const ManageUsers: React.FC = () => {
- {/* Structural Data Matrix Table Block */} {isLoading ? (
Loading library account directory... @@ -80,15 +120,14 @@ export const ManageUsers: React.FC = () => { - {filteredReaders.length === 0 ? ( + {usersList.length === 0 ? ( No matching user files found inside the database logs. ) : ( - filteredReaders.map((user) => ( - // ๐Ÿ’ก MODIFIED: Added triggering click parameters and pointer cursor to table row elements + usersList.map((user: UserRecord) => ( setSelectedUser(user)} @@ -109,13 +148,53 @@ export const ManageUsers: React.FC = () => {
+ +
+
+ Showing {usersList.length} of{" "} + {totalCount} metrics accounts logged +
+ +
+ + + {Array.from({ length: totalPages }, (_, idx) => idx + 1).map((pageNum) => ( + + ))} + + +
+
)} - {/* Add New User Overlay Portal Modal Frame */} setIsModalOpen(false)} /> - {/* ๐Ÿ’ก FIXED: Key prop forces component to clean-mount every time selectedUser changes, removing lint dependency loops completely */} { + const token = useAuthStore((state) => state.token); + // Hook 1: Readers Count inside AdminPanel.tsx + const { data: readersData, isLoading: isLoadingReaders } = useQuery({ + queryKey: ["adminReadersMasterFeed", token], + queryFn: async () => { + // Pass a query parameter limit to get the aggregate counter matrix + const res = await axiosClient.get("/admin/readers", { params: { limit: 1, offset: 0 } }); + return res.data?.data || res.data; + }, + enabled: !!token, + }); + + // Hook 2: Librarians Count inside AdminPanel.tsx + const { data: librariansData, isLoading: isLoadingLibrarians } = useQuery({ + queryKey: ["adminUsersMasterFeed", token], + queryFn: async () => { + const res = await axiosClient.get("/admin/librarians", { params: { limit: 1, offset: 0 } }); + return res.data?.data || res.data; + }, + enabled: !!token, + }); + + // Safely extract totalCount properties from the new controller wrapper object format + const totalReaders = readersData?.totalCount ?? 0; + const totalLibrarians = librariansData?.totalCount ?? 0; return ( -
+
{/* Premium Welcome Banner Matrix card */}

Welcome back, {"System Admin"}

- System access initialization verified. Use the main layout directory console to evaluate infrastructure configurations. + System access initialization verified. Select a configuration node below to audit active directory accounts.

{/* Grid Display Metrics Blocks */} -
-
-
- +
+ + {/* Gateway Card 1: Manage Users */} + +
+
+ +
+
+

Manage Users

+

+ {isLoadingReaders ? ( + Computing... + ) : ( + `${totalReaders} Active Readers` // ๐Ÿ’ก FIXED: Removed .length runtime bug + )} +

+
-
-

System Users

-

Active Directory

+
+
-
+ -
-
- + {/* Gateway Card 2: Manage Librarians */} + +
+
+ +
+
+

Manage Librarians

+

+ {isLoadingLibrarians ? ( + Computing... + ) : ( + `${totalLibrarians} Librarins Assigned` // ๐Ÿ’ก FIXED: Removed .length runtime bug + )} +

+
-
-

Librarians

-

Assigned Officers

+
+
-
+ -
-
- -
-
-

Core Logs

-

Operational

-
-
); diff --git a/client/src/features/fines/components/DeleteFinesModal.tsx b/client/src/features/fines/components/DeleteFinesModal.tsx index ca99744..96f0b47 100644 --- a/client/src/features/fines/components/DeleteFinesModal.tsx +++ b/client/src/features/fines/components/DeleteFinesModal.tsx @@ -1,3 +1,5 @@ +import { AlertTriangle, X, AlertCircle } from "lucide-react"; + interface DeleteFinesModalProps { isOpen: boolean; onClose: () => void; @@ -10,24 +12,59 @@ export const DeleteFinesModal = ({ isOpen, onClose, onConfirm, memberName, amoun if (!isOpen) return null; return ( -
-
-
- โš ๏ธ -

Delete Fine

-

- Are you sure you want to permanently delete the fine ledger invoice totaling โ‚น{amount}.00 registered against account holder "{memberName}"? -

-

- Warning: This action breaks reporting audit history loops. Proceed with caution. +

+
+ + {/* Top Close Control */} +
+ +
+ +
+ {/* Branded Alert Icon */} +
+ +
+ +

Purge Fine Invoice

+ +

+ Are you sure you want to permanently delete the ledger invoice totaling โ‚น{amount}.00 registered against "{memberName}"?

+ + {/* Audit Warning Box */} +
+ +

+ Execution Warning: This action breaks reporting audit history loops. Administrative caution required. +

+
-
- - +
+
); diff --git a/client/src/features/fines/components/FineDetailsModal.tsx b/client/src/features/fines/components/FineDetailsModal.tsx index e4df35e..2bc97d2 100644 --- a/client/src/features/fines/components/FineDetailsModal.tsx +++ b/client/src/features/fines/components/FineDetailsModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import type { FineRecord } from "../../../types/fines"; import { X, User, BookOpen, ShieldAlert, DollarSign, Trash2, CheckCircle2 } from "lucide-react"; -import { DeleteFinesModal } from "./DeleteFinesModal"; // Ensure this import path is correct +import { DeleteFinesModal } from "./DeleteFinesModal"; interface FineDetailsModalProps { isOpen: boolean; @@ -29,77 +29,101 @@ export const FineDetailsModal = ({ isOpen, fine, onClose, onSettle, onDelete }: return ( <> -
-
+
+
{/* Header */} -
+
-

About Fine

-

ID: FINE-{displayId}

+

About Fine Context

+

ID: FINE-{displayId}

-
-
+
{/* Account Holder Section */}
-

- Member Account Profile +

+ Member Account Profile

-
-
Full Name{fine.memberName}
-
Phone Number{fine.memberPhone || "N/A"}
-
Email Address{fine.memberEmail}
+
+
+ Full Name + {fine.memberName} +
+
+ Phone Number + {fine.memberPhone || "N/A"} +
+
+ Email Address + {fine.memberEmail} +
{/* Media Asset Section */}
-

- Circulated Media Target +

+ Circulated Media Target

-
-
Book Title๐Ÿ“– {fine.bookTitle}
-
Author{fine.bookAuthor}
-
Checkout Trigger Date{fine.borrowedDate}
+
+
+ Book Title + + {fine.bookTitle} + +
+
+ Author + {fine.bookAuthor} +
+
+ Checkout Trigger Date + {fine.borrowedDate} +
{/* Penalty Calculation */}
-

- Penalty Matrix Audit +

+ Penalty Matrix Audit

-
+
- - - - + + + + - + - - - + + + {breakdown.outsidePlanDays > 0 && ( - - + - - + + )} - - - + + +
ClauseDaysSubtotal
ClauseDaysSubtotal
Standard Plan{breakdown.withinPlanDays}dโ‚น{breakdown.withinPlanFine}.00Standard Plan Rate{breakdown.withinPlanDays}dโ‚น{breakdown.withinPlanFine}.00
- Out-of-Plan +
+ Out-of-Plan Climax {breakdown.outsidePlanDays}dโ‚น{breakdown.outsidePlanFine}.00{breakdown.outsidePlanDays}dโ‚น{breakdown.outsidePlanFine}.00
Total:โ‚น{fine.fine_amount}.00
Total Owed Ledger:โ‚น{fine.fine_amount}.00
@@ -108,18 +132,20 @@ export const FineDetailsModal = ({ isOpen, fine, onClose, onSettle, onDelete }:
{/* Footer Actions */} -
+
@@ -132,7 +158,7 @@ export const FineDetailsModal = ({ isOpen, fine, onClose, onSettle, onDelete }: onConfirm={() => { onDelete(fine.fine_id); setShowDeleteConfirm(false); - onClose(); // Close parent modal after delete + onClose(); }} memberName={fine.memberName} amount={fine.fine_amount} diff --git a/client/src/features/fines/components/FinesNotificationBanner.tsx b/client/src/features/fines/components/FinesNotificationBanner.tsx index eca109a..fe4fb5b 100644 --- a/client/src/features/fines/components/FinesNotificationBanner.tsx +++ b/client/src/features/fines/components/FinesNotificationBanner.tsx @@ -9,24 +9,25 @@ export const FinesNotificationBanner = ({ totalCount, totalUnpaidAmount }: Fines if (totalCount === 0) return null; return ( -
-
-
- +
+
+
+
-

Overdue Account Penalties Detected

-

+

Overdue Account Penalties Detected

+

{totalCount} active dynamic resource leaks processed at 12:00 AM tonight.

-
-
- Uncollected Portfolio - โ‚น{totalUnpaidAmount.toLocaleString()}.00 + +
+
+ Uncollected Portfolio + โ‚น{totalUnpaidAmount.toLocaleString()}.00
- +
); diff --git a/client/src/features/fines/components/RestoreFineModal.tsx b/client/src/features/fines/components/RestoreFineModal.tsx index a24d29b..d7fcbc1 100644 --- a/client/src/features/fines/components/RestoreFineModal.tsx +++ b/client/src/features/fines/components/RestoreFineModal.tsx @@ -12,22 +12,30 @@ export const RestoreFineModal = ({ isOpen, onClose, onConfirm, fine }: RestoreFi if (!isOpen || !fine) return null; return ( -
-
+
+
-
- +
+
-

Restore Ledger Entry

-

- Are you sure you want to revert the payment for "{fine.memberName}"? This will move the record back to the Active Defaulters list and re-calculate the total fine amount. +

Restore Ledger Entry

+

+ Are you sure you want to revert the payment transaction for "{fine.memberName}"? This will shift the record securely back to the Active Defaulters view deck.

-
- + +
+ diff --git a/client/src/features/fines/components/SettleFinePaymentModal.tsx b/client/src/features/fines/components/SettleFinePaymentModal.tsx index 3094274..ba260ed 100644 --- a/client/src/features/fines/components/SettleFinePaymentModal.tsx +++ b/client/src/features/fines/components/SettleFinePaymentModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import React, { useState } from "react"; import type { FineRecord } from "../../../types/fines"; import { X, Calendar, CheckSquare } from "lucide-react"; @@ -6,50 +6,82 @@ interface SettleFinePaymentModalProps { isOpen: boolean; fine: FineRecord | null; onClose: () => void; - onConfirmSettlement: (payload: { id: string; paidDate: string }) => void; + onConfirmSettlement: (payload: { id: string; paidDate: string; paymentMethod?: string }) => void; } export const SettleFinePaymentModal = ({ isOpen, fine, onClose, onConfirmSettlement }: SettleFinePaymentModalProps) => { const today = new Date().toISOString().split("T")[0]; const [selectedPaidDate, setSelectedPaidDate] = useState(today); + const [paymentMethod, setPaymentMethod] = useState<"CASH" | "CARD" | "UPI">("CASH"); if (!isOpen || !fine) return null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!selectedPaidDate) return; - onConfirmSettlement({ id: fine.fine_id, paidDate: selectedPaidDate }); + onConfirmSettlement({ + id: fine.fine_id, + paidDate: selectedPaidDate, + paymentMethod: paymentMethod + }); }; return ( -
-
+
+
{/* Header */} -
-

Execute Counter Settlement

-
-
+ {/* Quick Informational Vector */} -
-
+
+
Account Name: - {fine.memberName} + {fine.memberName}
-
+
Balance Due Ledger: - โ‚น{fine.fine_amount}.00 + โ‚น{fine.fine_amount}.00 +
+
+ + {/* Payment Method Selector Segment */} +
+ +
+ {(["CASH", "CARD", "UPI"] as const).map((method) => ( + + ))}
{/* Payment Calendar Target Input */} -
-