diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628358d..6d32441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,32 +1,52 @@ -name: CI Pipeline +name: Backend CI Pipeline on: push: - branches: [ develop ] + branches: + - main + - develop + pull_request: - branches: [ develop ] + branches: + - main + - develop jobs: - build-and-test: + backend-ci: runs-on: ubuntu-latest + + defaults: + run: + working-directory: server + + env: + NODE_ENV: test + PORT: 5000 + DATABASE_URL: postgresql://test:test@localhost:5432/testdb + JWT_SECRET: github-actions-secret + steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout Repository + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '18' + node-version: 22 + cache: npm + cache-dependency-path: server/package-lock.json - - name: Install dependencies - run: npm install + - name: Install Dependencies + run: npm ci - - name: Run Tests (Placeholder) - # Instead of running 'npm test', we just echo to pass the step - run: | - echo "Test suite is currently under development." - echo "Skipping formal test execution." - exit 0 + - name: TypeScript Build + run: npm run build + - name: Unit Tests + run: npm run test:unit + - name: Integration Tests + run: npm run test:integration + - name: Coverage Report + run: npm run test:coverage \ No newline at end of file diff --git a/client/index.html b/client/index.html index b59a51c..33d7e85 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + Library Management System diff --git a/client/public/logo.svg b/client/public/logo.svg new file mode 100644 index 0000000..2d5408b --- /dev/null +++ b/client/public/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/src/features/admin/components/AdminLayout.tsx b/client/src/features/admin/components/AdminLayout.tsx new file mode 100644 index 0000000..5d76ca3 --- /dev/null +++ b/client/src/features/admin/components/AdminLayout.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useAuthStore } from "../../../store/authStore"; +import { motion } from "framer-motion"; + +// Lucide Icons matching your clean, balanced stroke layouts +import { + LayoutDashboard, + Users, + ShieldAlert, + LogOut, + Library, + User +} from "lucide-react"; + +export const AdminLayout: React.FC = () => { + const { user, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleSignOut = () => { + logout(); + navigate("/login"); + }; + + const navItems = [ + { name: "Admin Dashboard", path: "/admin/dashboard", icon: LayoutDashboard, color: "text-blue-600" }, + { name: "Manage Users", path: "/admin/users", icon: Users, color: "text-amber-700" }, + { name: "Manage Librarians", path: "/admin/librarians", icon: ShieldAlert, color: "text-rose-600" }, + ]; + + return ( +
+ + {/* Sidebar Navigation - Warm Archival Minimalist Layout (Matching 72 width) */} + + + {/* Main Structural Area */} +
+ + {/* Header Frame - Formatted with identical Height h-22 and Side Padding */} +
+
+

Admin Dashboard

+

Core directory controls and active network administration

+
+ +
+
+

+ ROLE: {user?.role || "ADMIN"} +

+
+ + {/* User Profile Avatar Frame */} +
+ +
+
+
+ + {/* Content View Injection Portal using your clean transition specs */} +
+ + + +
+
+
+ ); +}; \ 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..bb965a0 --- /dev/null +++ b/client/src/features/admin/components/DeleteLibrarianProfile.tsx @@ -0,0 +1,73 @@ +import React from "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 ( +
+ {/* Container: Matches the clean off-white base with soft linen-amber border */} +
+ +
+ {/* Header: Crisp text-base alignment with deep slate-ink tone */} +

+ Revoke Administrative Access +

+ + {/* Main Paragraph: Structured at text-sm slate-600 for high editorial legibility */} +

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

+ + {/* Callout Block: Shipped with premium cream/rose warning surface tokens */} +
+ + โš ๏ธ Critical Reminder: + + This action completely cleanses their system profile registry. They will instantly lose terminal authentication rights and management access across the network. +
+
+ + {/* Modal Action Footers - Crisp Touchpoints */} +
+ + {/* Cancel/Abort Button */} + + + {/* Action Button: Dark editorial signature signature button block */} + + +
+
+
+ ); +}; \ 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..980f735 --- /dev/null +++ b/client/src/features/admin/components/DeleteUserModal.tsx @@ -0,0 +1,73 @@ +import React from "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 ( +
+ {/* Container: Matches the clean off-white base with soft linen-amber border */} +
+ +
+ {/* Header: Crisp text-base alignment with deep slate-ink tone */} +

+ Confirm User Account Purge +

+ + {/* Main Paragraph: Structured at text-sm slate-600 for high editorial legibility */} +

+ Are you sure you want to completely delete the system profile record for{" "} + "{userName}" from the library management core engine? +

+ + {/* Callout Block: Shipped with premium cream/rose warning surface tokens */} +
+ + โš ๏ธ Irreversible Action: + + This process instantly flushes out their system user registry parameters, active resource rentals, tracking workflows, and systemic archival logs completely. +
+
+ + {/* Modal Action Footers - Crisp Touchpoints */} +
+ + {/* Cancel Button */} + + + {/* Action Button: Dark editorial signature signature button block */} + + +
+
+
+ ); +}; \ 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..648a07e --- /dev/null +++ b/client/src/features/admin/components/LibrarianModal.tsx @@ -0,0 +1,257 @@ +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 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; + + // ๐Ÿ’ก FIXED: Initialize state directly from props. + // Combined with the unique layout `key`, this renders cleanly without a useEffect loop. + const [name, setName] = useState(librarianToEdit?.name || ""); + const [gmail, setGmail] = useState(librarianToEdit?.gmail || ""); + const [password, setPassword] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(librarianToEdit?.phone_number || ""); + const [errors, setErrors] = useState>({}); + + const handleResetAndClose = () => { + setName(""); + setGmail(""); + setPassword(""); + setPhoneNumber(""); + setErrors({}); + onClose(); + }; + + 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 + ? "Librarian details updated successfully." + : "New librarian authorized successfully" + ); + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + 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 = 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; + + librarianMutation.mutate(payload); + }; + + if (!isOpen) return null; + + return ( +
+
+ + {/* Header Layout Block */} +
+
+

+ {isEditMode ? "Modify Librarian Details" : "Add New Librarian"} +

+

+ {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.5 bg-slate-50 border text-slate-900 rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.name ? "border-rose-500 focus:ring-rose-500/5" : "border-slate-200 focus:ring-slate-900/5 focus:border-slate-900" + }`} + /> +
+ {errors.name &&

{errors.name}

} +
+ + {/* Email Field */} +
+ +
+ + setGmail(e.target.value)} + className={`w-full pl-9 pr-4 py-2.5 bg-slate-50 border text-slate-900 rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.gmail ? "border-rose-500 focus:ring-rose-500/5" : "border-slate-200 focus:ring-slate-900/5 focus:border-slate-900" + }`} + /> +
+ {errors.gmail &&

{errors.gmail}

} +
+ + {/* Password Field */} +
+ +
+ + setPassword(e.target.value)} + className={`w-full pl-9 pr-4 py-2.5 bg-slate-50 border text-slate-900 rounded-xl text-xs font-semibold tracking-wide transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.password ? "border-rose-500 focus:ring-rose-500/5" : "border-slate-200 focus:ring-slate-900/5 focus:border-slate-900" + }`} + /> +
+ {errors.password &&

{errors.password}

} +
+ + {/* Phone Number Field */} +
+ +
+ + setPhoneNumber(e.target.value.replace(/\D/g, ""))} + className={`w-full pl-9 pr-4 py-2.5 bg-slate-50 border text-slate-900 rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.phoneNumber ? "border-rose-500 focus:ring-rose-500/5" : "border-slate-200 focus:ring-slate-900/5 focus:border-slate-900" + }`} + /> +
+ {errors.phoneNumber &&

{errors.phoneNumber}

} +
+ + {/* Configuration Alert Badge */} +
+ + Enforced Authority Level: LIBRARIAN +
+ + {/* Action Footer Buttons */} +
+ + +
+ +
+
+
+ ); +}; \ 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..b2ffedd --- /dev/null +++ b/client/src/features/admin/components/LibrarianProfile.tsx @@ -0,0 +1,157 @@ +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 profile deleted successfully."); + queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + setIsDeleteModalOpen(false); + onBack(); + }, + onError: () => { + toast.error("Failed to cleanly flush operator entry."); + setIsDeleteModalOpen(false); + }, + }); + + return ( + <> +
+ + {/* Navigation Action Strip */} + + + {/* Identity Details Box */} +
+ + {/* Banner Layout: Swapped to high-contrast dark signature panel */} +
+ + System Node: Active Operator + + + {/* FLOATING ACTION MANAGEMENT SYSTEM PORTAL BUTTONS */} +
+ + +
+
+ +
+ {/* Avatar block using core premium styling metrics */} +
+ {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 || "No Phone Registered"} +
+
+ +
+ +
+ + {new Date(profile.created_at).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })} +
+
+
+ +
+
+
+ + {/* 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..57f3bb8 --- /dev/null +++ b/client/src/features/admin/components/ManageLibrarians.tsx @@ -0,0 +1,189 @@ +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { useAuthStore } from "../../../store/authStore"; +import { LibrarianProfile } from "./LibrarianProfile"; +import { LibrarianModal } from "./LibrarianModal"; +import { Mail, Phone, ArrowRight, UserPlus, ChevronLeft, ChevronRight } from "lucide-react"; + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + created_at: string; + 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 [selectedLibrarian, setSelectedLibrarian] = useState(null); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + const { data, isLoading } = useQuery({ + queryKey: ["adminUsersMasterFeed", token, currentPage], + queryFn: async () => { + const offset = (currentPage - 1) * itemsPerPage; + const res = await axiosClient.get("/admin/librarians", { + params: { + limit: itemsPerPage, + offset: offset, + } + }); + return res.data; + }, + enabled: !!token, + }); + + 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)); + + // ๐Ÿ’ก FIXED: If a librarian card has been clicked, display their profile views context immediately + if (selectedLibrarian) { + return ( +
+ setSelectedLibrarian(null)} + /> +
+ ); + } + + return ( +
+ + {/* HEADER CONTROLS */} +
+
+

Library Operators

+

Manage staff terminals, clearance logs, and authority configurations.

+
+ +
+ + {isLoading ? ( +
+ Sifting team allocation profiles... +
+ ) : ( +
+ + {/* SCROLLABLE GRID AREA */} +
+
+ {librariansList.length === 0 ? ( +
+ No registered operator accounts tracked on this network. +
+ ) : ( + librariansList.map((librarian: UserRecord) => ( +
setSelectedLibrarian(librarian)} + className="group bg-white p-5 rounded-2xl border border-slate-100 shadow-xs hover:shadow-md hover:border-slate-200 transition-all cursor-pointer flex flex-col justify-between" + > +
+
+
+ {librarian.name.slice(0, 2)} +
+ + OP-{librarian.user_id.slice(-4).toUpperCase()} + +
+ +

+ {librarian.name} +

+ +
+

{librarian.gmail}

+

{librarian.phone_number || "No Phone Registered"}

+
+
+ +
+ View Full Operator Profile + +
+
+ )) + )} +
+
+ + {/* STICKY FIXED PAGINATION BAR */} +
+
+ + Page {currentPage} / {totalPages} | Total {totalCount} Librarians + +
+
+ + +
+
+ +
+ )} + + {/* CREATION FILE OVERLAY PORTAL CONTAINER */} + setIsCreateModalOpen(false)} + /> +
+ ); +}; \ 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..12d5541 --- /dev/null +++ b/client/src/features/admin/components/ManageUsers.tsx @@ -0,0 +1,238 @@ +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { useAuthStore } from "../../../store/authStore"; +import { Plus, Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { UserModal } from "./UserModal"; +import { UserDetailsModal } from "./UserDetailsModal"; + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + created_at: string; + role: "READER" | "LIBRARIAN"; +} + +interface PaginatedUserResponse { + data: UserRecord[]; + totalCount: number; +} + +interface ServerApiResponse { + success: boolean; + message: string; + data: PaginatedUserResponse | UserRecord[]; +} + +export const ManageUsers: React.FC = () => { + const token = useAuthStore((state) => state.token); + + // Server-driven lookup filter parameters matching your portal pattern + const [currentPage, setCurrentPage] = useState(1); + const [searchQuery, setSearchQuery] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const itemsPerPage = 10; + + // Card Overlay state tracking management + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + + // Core background querying pipeline mapping directly to server indices + const { data, isLoading } = useQuery({ + queryKey: ["adminUsersMasterFeed", token, currentPage, searchQuery, roleFilter], + queryFn: async () => { + const offset = (currentPage - 1) * itemsPerPage; + + const res = await axiosClient.get("/admin/readers", { + params: { + limit: itemsPerPage, + offset: offset, + search: searchQuery || undefined, + role: roleFilter || undefined // Built-in support for granular filter parameters + } + }); + + return res.data; + }, + enabled: !!token, + }); + + const responsePayload = data?.data; + + // Normalizing records gracefully safely checking standard API patterns + 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 handleClearFilters = () => { + setSearchQuery(""); + setRoleFilter(""); + setCurrentPage(1); + }; + + return ( +
+ + {/* Page Core Header Block - Spacious Layout Ivory Card */} +
+
+

Users Database Management

+

+ Click any system row ledger to view access settings, check full operational logs, or customize user system parameters. +

+
+ +
+ + {/* Control Utility Filter Toolbar Line */} +
+
+ { setSearchQuery(e.target.value); setCurrentPage(1); }} + className="w-full pl-11 pr-4 py-2 bg-slate-50 border border-slate-200 text-slate-900 rounded-xl text-xs sm:text-sm font-medium placeholder:text-slate-400 focus:bg-white focus:border-slate-900 outline-hidden focus:ring-4 focus:ring-slate-900/5 transition-all" + /> + +
+ +
+ + +
+
+ + {/* Master Content Ledger Grid Table */} + {isLoading ? ( +
+ Syncing System Account Directory... +
+ ) : ( +
+
+
+ + + + + + + + + + + + {usersList.length === 0 ? ( + + + + ) : ( + usersList.map((user: UserRecord) => ( + setSelectedUser(user)} + className="hover:bg-slate-50 transition-colors cursor-pointer group select-none" + > + + + + + + + )) + )} + +
System IDUser NameContact CredentialsAccount RoleEntry Date
+ No active matching subscriber accounts found on server indexing. +
+ USR-{user.user_id.slice(-4)} + + {user.name} + +
{user.gmail}
+
+ {user.phone_number || "No Phone Contact"} +
+
+ + {user.role} + + + {new Date(user.created_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + })} +
+
+ + {/* Pagination Navigation Footer Deck */} +
+ + Page {currentPage} / {totalPages} | Total {totalCount} Users + +
+ + +
+
+ +
+
+ )} + + {/* Popup Form Modals Layers */} + setIsModalOpen(false)} /> + + 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..fdb2844 --- /dev/null +++ b/client/src/features/admin/components/UserDetailsModal.tsx @@ -0,0 +1,346 @@ +import React, { useState } from "react"; +import { User, Mail, Phone, Calendar, Shield, Edit2, Trash2, Check, RotateCcw, KeyRound } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { toast } from "sonner"; +import { AxiosError } from "axios"; +import { DeleteUserModal } from "./DeleteUserModal"; + +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; + password?: string; + 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); + + 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 || ""); + 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); + onClose(); + }, + 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 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 = "Must register a valid structured @gmail.com handle."; + } + + const phoneRegex = /^\d{10}$/; + if (!phoneNumber) { + localErrors.phoneNumber = "Phone connectivity parameters are required."; + } else if (!phoneRegex.test(phoneNumber)) { + localErrors.phoneNumber = "Must be a 10-digit numeric character lineup."; + } + + if (!password) { + localErrors.password = "Security credential string allocation is required."; + } 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, + }); + }; + + const handleRevert = () => { + setName(user.name); + setGmail(user.gmail); + setPhoneNumber(user.phone_number); + setPassword(user.password || ""); + setErrors({}); + setIsEditing(false); + }; + + const handleMasterClose = () => { + handleRevert(); + onClose(); + }; + + return ( + <> + {/* High contrast layout backdrop with frosting filter matching MemberDetailsModal */} +
+
+ + {/* Header Grid Framework - Clean Bright Banner matching screen formats */} +
+
+

+ {isEditing ? "Edit User Profile" : "User Account Information"} +

+

+ UUID: USR-{user.user_id ? user.user_id.split("-").pop()?.slice(-6).toUpperCase() : "000000"} +

+
+ +
+ + {/* Content Box Switcher Container */} +
+ + {/* Input fields / View Data fields container stack */} +
+ + {/* Full Name Section */} +
+ Full Name + {isEditing ? ( +
+ + setName(e.target.value)} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.name + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ ) : ( + {user.name} + )} + {errors.name &&

{errors.name}

} +
+ + {isEditing &&
} + + {/* Dynamic Interactive Input Grid Setup */} +
+ + {/* Email Entry Section */} +
+ Email Address + {isEditing ? ( +
+ + setGmail(e.target.value)} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.gmail + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ ) : ( + {user.gmail} + )} + {errors.gmail &&

{errors.gmail}

} +
+ + {/* Phone Number Entry Section */} +
+ Phone Number + {isEditing ? ( +
+ + setPhoneNumber(e.target.value.replace(/\D/g, ""))} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.phoneNumber + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ ) : ( + {user.phone_number || "No Verified Phone"} + )} + {errors.phoneNumber &&

{errors.phoneNumber}

} +
+ + {/* Password Configuration String Row */} +
+ Password + {isEditing ? ( +
+ + setPassword(e.target.value)} + placeholder="Assign new plain text system credential mapping" + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.password + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ ) : ( + + {user.password || "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข"} + + )} + {errors.password &&

{errors.password}

} +
+
+ + {!isEditing && ( + <> +
+ + {/* Immutable Metadata Dashboard Blocks */} +
+
+ +
+ Security Role Status + {user.role} +
+
+ +
+ +
+ System Enrollment Date + + {new Date(user.created_at).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })} + +
+
+
+ + )} + +
+ + {/* Operations Actions Layout Interface Tray Footer */} +
+ {isEditing ? ( + <> + + + + ) : ( +
+ + + +
+ )} +
+ +
+
+
+ + {/* Secure Action Delete Confirmation Sub-modal stack overlay */} + 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..c834777 --- /dev/null +++ b/client/src/features/admin/components/UserModal.tsx @@ -0,0 +1,266 @@ +import React, { useState } from "react"; +import { 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>({}); + + // Reset helper invoked during form completion or dismissal + const handleForcedReset = () => { + setName(""); + setGmail(""); + setPassword(""); + setConfirmPassword(""); + setPhoneNumber(""); + setErrors({}); + onClose(); + }; + + // 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"] }); + handleForcedReset(); + }, + onError: (error: AxiosError) => { + console.error("Account Creation Failed:", error); + const serverMessage = error.response?.data?.message; + toast.error(serverMessage || "Failed to finalize account registry."); + }, + }); + + const validateForm = () => { + const localErrors: Record = {}; + + if (!name.trim()) localErrors.name = "Full name 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 ( +
+
+ + {/* Modal Branding Header - Slate-900 Core Banner Matching MemberModal */} +
+
+

Add New Reader Profile

+

All system configuration inputs are mandatory

+
+ +
+ + {/* Input Interactive form area */} +
+ + {/* Full Name Input */} +
+ +
+ + setName(e.target.value)} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.name + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ {errors.name &&

{errors.name}

} +
+ + {/* Email Address Input */} +
+ +
+ + setGmail(e.target.value)} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.gmail + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ {errors.gmail &&

{errors.gmail}

} +
+ + {/* Security Credentials Block Grid Row */} +
+ {/* Password */} +
+ +
+ + setPassword(e.target.value)} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-mono tracking-wide font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.password + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+
+ + {/* Confirm Password */} +
+ +
+ + setConfirmPassword(e.target.value)} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-mono tracking-wide font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.confirmPassword + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+
+
+ {(errors.password || errors.confirmPassword) && ( +

+ {errors.password || errors.confirmPassword} +

+ )} + + {/* Mobile Connectivity Number Input */} +
+ +
+ + setPhoneNumber(e.target.value.replace(/\D/g, ""))} + className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 border rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ + errors.phoneNumber + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-slate-200 text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + }`} + /> +
+ {errors.phoneNumber &&

{errors.phoneNumber}

} +
+ + {/* Enforced Encampment Parameters Verification Card Box */} +
+ + Enforced Account Context : READER ACCESS ONLY +
+ + {/* Action Footer Frame */} +
+ + +
+ +
+
+
+ ); +}; \ 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..92a8929 --- /dev/null +++ b/client/src/features/admin/pages/AdminPanel.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { axiosClient } from "../../../api/axiosClient"; +import { useAuthStore } from "../../../store/authStore"; +import { Users, ArrowUpRight, ShieldCheck } from "lucide-react"; + +export const AdminPanel: React.FC = () => { + const token = useAuthStore((state) => state.token); + + // Hook 1: Readers Count inside AdminPanel.tsx + const { data: readersData, isLoading: isLoadingReaders } = useQuery({ + queryKey: ["adminReadersMasterFeed", token], + queryFn: async () => { + 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, + }); + + const totalReaders = readersData?.totalCount ?? 0; + const totalLibrarians = librariansData?.totalCount ?? 0; + + return ( +
+ + {/* Premium Welcome Banner Matrix card */} +
+
+

+ Welcome back, System Admin +

+

+ System access initialization verified. Select a configuration node below to audit, update, and manage active directory accounts. +

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

Directory Control

+

+ {isLoadingReaders ? ( + Computing Matrix... + ) : ( + `${totalReaders} Active Readers` + )} +

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

Staff Management

+

+ {isLoadingLibrarians ? ( + Computing Matrix... + ) : ( + `${totalLibrarians} Librarians Assigned` + )} +

+
+
+
+ +
+ + +
+
+ ); +}; \ 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..45079eb 100644 --- a/client/src/features/auth/pages/Login.tsx +++ b/client/src/features/auth/pages/Login.tsx @@ -6,17 +6,16 @@ import { axiosClient } from "../../../api/axiosClient"; import { LoginSchema } from "../../../types/schemas"; import { toast } from "sonner"; import { motion } from "framer-motion"; +import { BookOpen, Mail, Lock, Eye, EyeOff } from "lucide-react"; 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 }>({}); @@ -26,12 +25,7 @@ export const Login = () => { setIsLoading(true); setFieldErrors({}); - // 1. Client-Side Parsing via your frontend schema - const parsingResults = LoginSchema.safeParse({ - email, - password, - role: selectedRole, - }); + const parsingResults = LoginSchema.safeParse({ email, password, role: selectedRole }); if (!parsingResults.success) { const structuredErrors: { email?: string; password?: string } = {}; @@ -45,14 +39,8 @@ export const Login = () => { 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 networkResponse = await axiosClient.post("/auth/login", { gmail: email, password, role: selectedRole }); const targetPayload = networkResponse.data?.data || networkResponse.data; const { user, token } = targetPayload; @@ -61,13 +49,19 @@ export const Login = () => { return; } - setAuth(user, token); - toast.success("Login Successfully"); - navigate("/dashboard"); - + 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("Librarian Logged In Successfully"); + navigate("/dashboard"); + } else { + toast.error("Access Denied: Role mismatch error."); + navigate("/login"); + } } catch (error: unknown) { - console.error("Login Failed:", error); - if (axios.isAxiosError(error)) { toast.error(error.response?.data?.message || "Invalid account credentials."); } else { @@ -79,129 +73,90 @@ export const Login = () => { }; return ( -
- {/* Decorative Branding Background Blobs */} -
-
- +
- {/* Core Presentation Branding Shield */} -
-
- ๐Ÿ“š + {/* Branding Header */} +
+
+
-

Welcome to

-

Library Management System

+

Library Management

+

Authentication Portal

-
- {/* RBAC Role Selector Tabs System */} + + {/* Role Tabs */}
- -
+ +
- {/* Email Destination Input Structure */} + {/* Email Input */}
- -
- โœ‰๏ธ + +
+ setEmail(e.target.value)} - className={`w-full pl-9 pr-4 py-2.5 bg-canvas-dominant border text-slate-secondary rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ - fieldErrors.email - ? "border-utility-crimson focus:ring-utility-crimson/10 focus:border-utility-crimson" - : "border-slate-light/10 focus:ring-sage-primary/10 focus:border-sage-primary" - }`} + className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-semibold text-slate-900 outline-none focus:border-slate-900 focus:ring-4 focus:ring-slate-900/5 transition-all" />
- {fieldErrors.email &&

{fieldErrors.email}

} + {fieldErrors.email &&

{fieldErrors.email}

}
- {/* Password Security Input Structure with Visibility Toggle */} + {/* Password Input */}
- -
-
- ๐Ÿ”’ +
+ setPassword(e.target.value)} - className={`w-full pl-9 pr-10 py-2.5 bg-canvas-dominant border text-slate-secondary rounded-xl text-sm font-semibold transition-all outline-hidden focus:bg-white focus:ring-4 ${ - fieldErrors.password - ? "border-utility-crimson focus:ring-utility-crimson/10 focus:border-utility-crimson" - : "border-slate-light/10 focus:ring-sage-primary/10 focus:border-sage-primary" - }`} + className="w-full pl-10 pr-10 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-semibold text-slate-900 outline-none focus:border-slate-900 focus:ring-4 focus:ring-slate-900/5 transition-all" /> -
- {fieldErrors.password &&

{fieldErrors.password}

} + {fieldErrors.password &&

{fieldErrors.password}

}
- {/* Core Submission Trigger Module */} diff --git a/client/src/features/books/components/BookDetailModal.tsx b/client/src/features/books/components/BookDetailModal.tsx index 04f9b43..5ef95f1 100644 --- a/client/src/features/books/components/BookDetailModal.tsx +++ b/client/src/features/books/components/BookDetailModal.tsx @@ -1,11 +1,16 @@ +import { useState } from "react"; import type { BookInventoryItem } from "../../../types/books"; +import { DeleteBookModal } from "./DeleteBookModal"; + +// Editorial Visual Assets +import { BookOpen, User, Layers, Calendar, Archive, BookmarkCheck, BarChart3, Edit3, Trash2, X } from "lucide-react"; interface BookDetailModalProps { isOpen: boolean; onClose: () => void; book: BookInventoryItem | null; onEditTrigger: () => void; - onDeleteTrigger: () => void; + onDeleteTrigger: () => void; // Restored the original prop name here } export const BookDetailModal = ({ @@ -13,8 +18,11 @@ export const BookDetailModal = ({ onClose, book, onEditTrigger, - onDeleteTrigger, + onDeleteTrigger, // Restored here }: BookDetailModalProps) => { + // Internal state tracking to switch over to the confirmation display layer + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + if (!isOpen || !book) return null; // Format the ISO database timestamp into a human-readable calendar date @@ -26,81 +34,128 @@ export const BookDetailModal = ({ minute: "2-digit", }); - return ( -
-
- - {/* Header section */} -
-
-

Book Details

-

- ID: BOOK-{book.id ? String(book.id).slice(-4).toUpperCase() : "0000"} -

-
- -
- - {/* Detailed Metadata Grid */} -
-
- -
{book.title}
-
+ const handleConfirmDeletion = () => { + setIsDeleteOpen(false); // Close the local delete confirmation modal state + onClose(); // Close the core details framework drawer + onDeleteTrigger(); // Execute the original deletion action from BooksPage.tsx + }; -
- -
{book.author}
-
- -
-
- -
{book.categoryName}
-
+ return ( + <> +
+
+ + {/* Header section */} +
- -
{shelfEntryDate}
+

Book Details

+

+ ID: BOOK-{book.id ? String(book.id).slice(-4).toUpperCase() : "0000"} +

+
-
+ {/* Detailed Metadata Grid */} +
- Total Owned - {book.totalCopies} + +
+ {book.title} +
+
- On Shelf - {book.availableCopies} + +
+ {book.author} +
-
- Lending Count - {book.lendingCount}ร— + +
+
+ +
+ {book.categoryName} +
+
+
+ +
+ {shelfEntryDate} +
+
+
+ +
+
+ + Total Owned + + + {book.totalCopies} + +
+
+ + On Shelf + + + {book.availableCopies} + +
+
+ + Lending Count + + + {book.lendingCount}ร— + +
-
- {/* Action Panel Footer */} -
- - + {/* Action Panel Footer */} +
+ + +
-
+ + {/* Embedded inline confirmation screen */} + setIsDeleteOpen(false)} + onConfirm={handleConfirmDeletion} + bookTitle={book.title} + /> + ); }; \ No newline at end of file diff --git a/client/src/features/books/components/BookModal.tsx b/client/src/features/books/components/BookModal.tsx index 87ded7b..74e8362 100644 --- a/client/src/features/books/components/BookModal.tsx +++ b/client/src/features/books/components/BookModal.tsx @@ -6,6 +6,9 @@ import type { BookCategory, BookInventoryItem } from "../../../types/books"; import { axiosClient } from "../../../api/axiosClient"; import { toast } from "sonner"; +// Editorial Visual Assets +import { Sparkles, BookOpen, User, Layers, Hash, X } from "lucide-react"; + interface ScoredLine { originalText: string; translatedText: string; @@ -84,7 +87,7 @@ export const BookModal = ({ isOpen, onClose, onSubmit, categories, editingBook } setOcrAlternatives(payload.alternativeLines); } - // AUTOFILL EXECUTION: Hydrates form controllers with pristine Gemini data properties directly + // AUTOFILL EXECUTION: Hydrates form controllers directly setValue("title", payload.title || ""); setValue("author", payload.author || ""); @@ -114,83 +117,156 @@ export const BookModal = ({ isOpen, onClose, onSubmit, categories, editingBook } if (!isOpen) return null; return ( -
-
0 ? "w-full max-w-4xl" : "w-full max-w-md"}`}> +
+
0 ? "w-full max-w-4xl" : "w-full max-w-lg"}`}> {/* LEFT COMPONENT: Primary Form Input Layout */} -
-
-

{editingBook ? "Modify Details" : "Add New Book"}

- +
+ + {/* Modal Branding Header - Clean Dark Structured Banner */} +
+

+ {editingBook ? "Modify Details" : "Add New Book"} +

+
- {!editingBook && ( -
-
- - Scans: {scanCounter}/10 +
+ {!editingBook && ( +
+
+ + + Scans: {scanCounter}/10 + +
+ = 10} + className="block w-full text-xs text-slate-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-xl file:border-0 file:text-xs file:font-bold file:bg-slate-900 file:text-white hover:file:bg-slate-800 transition-all cursor-pointer" + />
- = 10} - className="block w-full text-xs text-slate-light file:mr-4 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-bold file:bg-sage-primary file:text-white hover:file:bg-sage-primary/90 transition-all cursor-pointer" - /> -
- )} - -
-
- - - {errors.title &&

{errors.title.message}

} -
+ )} -
- - - {errors.author &&

{errors.author.message}

} -
- -
+
- - + + + {errors.title &&

{errors.title.message}

}
+
- - + + + {errors.author &&

{errors.author.message}

}
-
-
- - -
-
+
+
+ + +
+
+ +
+ +
+
+
+ + {/* Action Footer Frame */} +
+ + +
+ +
{/* RIGHT COMPONENT: Interactive Diagnostic Mapping Helper Column */} {!editingBook && ocrAlternatives.length > 0 && ( -
-

๐Ÿ”ฎ OCR Raw Layout Fragments

-

If the AI missed a specific structural block, click a tag location element to force-overwrite values:

+
+

+ OCR Layout Fragments +

+

+ Select an isolated segment directly to re-map data properties down into the text inputs: +

{ocrAlternatives.map((item, idx) => { return ( -
-
{item.translatedText}
+
+
{item.translatedText}
{item.originalText !== item.translatedText && ( -
Original OCR: "{item.originalText}"
+
+ Raw: "{item.originalText}" +
)} -
- - +
+ +
); diff --git a/client/src/features/books/components/DeleteBookModal.tsx b/client/src/features/books/components/DeleteBookModal.tsx index 7007718..f83f568 100644 --- a/client/src/features/books/components/DeleteBookModal.tsx +++ b/client/src/features/books/components/DeleteBookModal.tsx @@ -1,3 +1,6 @@ +// Editorial Visual Assets +import { Trash2 } from "lucide-react"; + interface DeleteBookModalProps { isOpen: boolean; onClose: () => void; @@ -9,32 +12,39 @@ export const DeleteBookModal = ({ isOpen, onClose, onConfirm, bookTitle }: Delet if (!isOpen) return null; return ( -
-
-
- ๐Ÿ—‘๏ธ -

Delete Book From Catalog

-

- Are you absolutely sure you want to delete "{bookTitle}" permanently from the library repository index? +

+
+
+
+ +
+ +

+ Delete Book From Catalog +

+ +

+ Are you absolutely sure you want to delete "{bookTitle}" permanently from the library repository index?

-

+ +

Warning: This action cannot be reverted and will permanently delete all tracking history and copy instances.

{/* Action Panel Buttons */} -
+
diff --git a/client/src/features/books/pages/BooksPage.tsx b/client/src/features/books/pages/BooksPage.tsx index 954e741..c9b77fd 100644 --- a/client/src/features/books/pages/BooksPage.tsx +++ b/client/src/features/books/pages/BooksPage.tsx @@ -8,6 +8,9 @@ import type { BookInventoryItem, BookCategory } from "../../../types/books"; import type { BookFormValues } from "../schemas/bookSchema"; import { toast } from "sonner"; +// Editorial Icon Elements +import { Search, Plus, RotateCcw, Award, BookOpen, ChevronLeft, ChevronRight } from "lucide-react"; + export const BooksPage = () => { const queryClient = useQueryClient(); @@ -167,36 +170,43 @@ export const BooksPage = () => { }); return ( -
+
{/* Action Title Jumbotron Header block */} -
+
-

Library Books Catalog

-

View, evaluate, and trace total volume distribution limits across the facility.

+

Books Management Desk

+

+ View, evaluate, and trace total volume distribution limits across the facility. +

{/* Query Filter Navigation Controls Line */} -
-
+
+
setLocalSearch(e.target.value)} - className="sm:col-span-2 px-3.5 py-2 bg-canvas-dominant border border-slate-light/10 text-slate-secondary rounded-xl text-sm font-semibold focus:bg-white outline-hidden focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary transition-all" + className="w-full pl-11 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-xl text-xs sm:text-sm font-medium text-slate-900 placeholder:text-slate-400 outline-hidden focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 focus:bg-white transition-all" /> + +
+ +
setEditName(e.target.value)} - className="w-full px-4 py-2.5 border border-slate-300 rounded-xl text-sm font-medium focus:ring-2 focus:ring-sage-primary/20 focus:border-sage-primary outline-none transition-all" + className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 text-sm font-semibold text-slate-900 rounded-xl placeholder:text-slate-400 outline-hidden focus:bg-white focus:border-slate-900 focus:ring-4 focus:ring-slate-900/5 transition-all" /> - {localError &&

{localError}

} + {localError &&

{localError}

}
) : ( -

+

{category.category_name} -

+
)}
+ {/* Stats Row layout */}
-
- Total Owned Books - {category.booksCount} +
+ + Total Owned Books + + + {category.booksCount} +
-
- Total Borrows - {category.lendingCount} + +
+ + Total Borrows + + + {category.lendingCount} +
-
-

Created on: {new Date(category.created_at).toLocaleDateString()}

+ {/* Registry Date layout */} +
+ +
+ {new Date(category.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + })} +
- {/* Footer Actions Panel */} -
+ {/* Action Panel Footer */} +
-
- {isEditing ? ( - <> - - - - ) : ( + {isEditing ? ( + <> - )} -
+ + + ) : ( + + )}
diff --git a/client/src/features/categories/pages/ManageCategories.tsx b/client/src/features/categories/pages/ManageCategories.tsx index 717261c..43f8bf7 100644 --- a/client/src/features/categories/pages/ManageCategories.tsx +++ b/client/src/features/categories/pages/ManageCategories.tsx @@ -8,6 +8,22 @@ import ConfirmationModal from "../../returnedbooks/components/ConfirmationModal" import { categoryFormSchema } from "../validation/category.validation"; import type { CategoryMetrics } from "../types/category.types"; +// Editorial Visual Assets +import { + Plus, + Search, + X, + ChevronDown, + RotateCcw, + Layers, + BookOpen, + RefreshCw, + AlertTriangle, + FolderPlus, + ChevronLeft, + ChevronRight, +} from "lucide-react"; + interface UpdateMutationResponse { data?: { category_name?: string; @@ -151,27 +167,31 @@ export const ManageCategories = () => { const isFilterActive = searchQuery.trim() !== "" || bookSort !== "NONE" || borrowSort !== "NONE"; return ( -
+
{/* Top Deck Banner */} -
+
-

Manage Catalog Categories

-

Control library book categories, analyze category engagement volumes, and manage inventory segments.

+

Categories Management Desk

+

Control library book categories, analyze category engagement volumes, and manage inventory segments.

{/* Control Filtering Deck */} -
+
{/* Search Input Box Container */}
+ + + { setSearchQuery(e.target.value); setCurrentPage(1); }} - className="w-full pl-3 pr-10 py-2 bg-canvas-dominant border border-slate-light/10 text-slate-secondary rounded-xl text-sm font-semibold outline-hidden focus:bg-white focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary transition-all" + className="w-full pl-11 pr-11 py-2 bg-slate-50 border border-slate-200 text-slate-900 rounded-xl text-xs sm:text-sm font-medium outline-hidden focus:bg-white focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" /> {searchQuery && ( )}
{/* Filters Grouping Grid */}
- - - +
+ + + + +
+ +
+ + + + +
{/* Global Reset Action Trigger */} {isFilterActive && ( )}
{/* Main Grid Render Table Section */} {isLoading ? ( -
+
+ Compiling catalog taxonomy architecture indices...
) : (
-
+
- - - - + + + + - + {categoriesList.length === 0 ? ( - ) : ( @@ -263,19 +296,19 @@ export const ManageCategories = () => { { setSelectedCategory(cat); setIsDetailsOpen(true); }} - className="hover:bg-canvas-dominant/60 transition-colors cursor-pointer group select-none animate-fade-in" + className="hover:bg-slate-50/80 transition-colors cursor-pointer group select-none animate-fade-in" > - - -
Category Name ListingTotal Volume StackActive Circulation Borrow Count
Category Name ListingTotal Volume StackActive Circulation Borrow Count
- No library category profiles matches the search key terms. + + No library category profiles match the search key terms.
+ {cat.category_name} + {cat.booksCount} books - + 15 - ? "bg-sage-primary/10 text-sage-primary border-sage-primary/10" - : "bg-canvas-dominant text-slate-light border-slate-light/10" + ? "bg-emerald-50 text-emerald-700 border-emerald-100" + : "bg-slate-100 text-slate-500 border-slate-200" }`}> {cat.lendingCount} times @@ -286,71 +319,108 @@ export const ManageCategories = () => {
+ + {totalPages > 0 && ( +
+ + Page {currentPage} / {totalPages} | Total {totalRecordsCount} Categories + +
+ + +
+
+ )}
{/* Pagination Controls Block UI Section */} -
- - Showing page {currentPage} of {totalPages} ({totalRecordsCount} Archives) + {/*
+ + Showing page {currentPage} of {totalPages} ({totalRecordsCount} Archives) -
+
-
+
*/} + + +
)} {/* Modal A: Pop-up Form for Quick Add Creation */} {isCreateOpen && ( -
-
setIsCreateOpen(false)} /> +
+
setIsCreateOpen(false)} />
-

Add New Category Classification

-

Input a unique classification name to initialize shelf tags mapping slots.

+

+ Add Category Classification +

+

Input a unique classification name to initialize shelf tags mapping slots.

-
- +
+ ) => setNewCatName(e.target.value)} - className="w-full px-3.5 py-2 bg-canvas-dominant border border-slate-light/10 text-slate-secondary rounded-xl text-sm font-semibold focus:bg-white outline-hidden focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary transition-all" + className="w-full px-4 py-2 bg-slate-50 border border-slate-200 text-slate-900 rounded-xl text-xs sm:text-sm font-bold focus:bg-white outline-hidden focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" /> - {createValidationError &&

{createValidationError}

} + {createValidationError && ( +

+ {createValidationError} +

+ )}
-
+
diff --git a/client/src/features/dashboard/components/AmnestySimulator.tsx b/client/src/features/dashboard/components/AmnestySimulator.tsx index b8ac4bf..12375d0 100644 --- a/client/src/features/dashboard/components/AmnestySimulator.tsx +++ b/client/src/features/dashboard/components/AmnestySimulator.tsx @@ -1,27 +1,44 @@ -interface SimulatorProps { totalOutstanding: number; discount: number; onChange: (v: number) => void } +import { Sparkles } from "lucide-react"; + +interface SimulatorProps { + totalOutstanding: number; + discount: number; + onChange: (v: number) => void; +} export const AmnestySimulator = ({ totalOutstanding, discount, onChange }: SimulatorProps) => { const waivedFine = (totalOutstanding * (discount / 100)).toFixed(0); const projectedRecovery = (totalOutstanding - Number(waivedFine)).toFixed(0); return ( -
+
-

โšก Bulk Amnesty Clearance Simulator

-

Model the financial recovery outcome of offering a percentage-based amnesty fine relief.

+

+ Bulk Amnesty Clearance Simulator +

+

+ Model the financial recovery outcome of offering a percentage-based amnesty fine relief. +

-
-
- Relief Rate: {discount}% - Projected Cash Collection: โ‚น{projectedRecovery} + +
+
+ Relief Rate: {discount}% + Projected Cash Collection: โ‚น{projectedRecovery}
+ onChange(Number(e.target.value))} - className="w-full h-1.5 bg-gray-100 rounded-lg appearance-none cursor-pointer accent-teal-600" + type="range" + min="0" + max="100" + value={discount} + onChange={(e) => onChange(Number(e.target.value))} + className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-slate-900 focus:outline-hidden" />
-
- Offering this clearance drops โ‚น{waivedFine} in toxic debt and creates an immediate collection incentive for unreturned books. + +
+ Offering this clearance drops โ‚น{waivedFine} in toxic debt and creates an immediate collection incentive for unreturned books.
); diff --git a/client/src/features/dashboard/components/CategoryTreeMap.tsx b/client/src/features/dashboard/components/CategoryTreeMap.tsx index b04e370..3482320 100644 --- a/client/src/features/dashboard/components/CategoryTreeMap.tsx +++ b/client/src/features/dashboard/components/CategoryTreeMap.tsx @@ -1,26 +1,41 @@ +// Editorial Visual Assets +import { PieChart } from "lucide-react"; + export const CategoryTreeMap = ({ categories }: { categories: { name: string; value: number; color: string }[] }) => { const totalValue = categories.reduce((sum, c) => sum + c.value, 0) || 1; return ( -
+
-

๐Ÿท๏ธ Circulation Share by Genre

-

Live operational density mapping across catalog disciplines.

+

+ Circulation Share by Genre +

+

+ Live operational density mapping across catalog disciplines. +

-
+ + {/* Structured Distribution Density Bar */} +
{categories.map((cat) => (
+ {/* Subtle overlay tracking on interactive cell selection */}
))}
-
+ + {/* Categorical Color Mapping Grid Legend */} +
{categories.map((cat) => ( -
- - {cat.name} ({cat.value}) +
+ + + {cat.name} ({cat.value}) +
))}
diff --git a/client/src/features/dashboard/components/CriticalDeficitWidget.tsx b/client/src/features/dashboard/components/CriticalDeficitWidget.tsx index 8d0cd11..1c9a7e7 100644 --- a/client/src/features/dashboard/components/CriticalDeficitWidget.tsx +++ b/client/src/features/dashboard/components/CriticalDeficitWidget.tsx @@ -1,3 +1,6 @@ +// Editorial Visual Assets +import { AlertCircle } from "lucide-react"; + interface DeficitItem { id: string; name: string; @@ -5,34 +8,38 @@ interface DeficitItem { } export const CriticalDeficitWidget = ({ items }: { items: DeficitItem[] }) => ( -
+
-

๐Ÿšจ Procurement Alerts

- {/* ๐Ÿ’ก UPDATED: Reflects that these are completely out-of-stock items */} -

Critical titles with 0 copies remaining on the shelves.

+

+ Procurement Alerts +

+

+ Critical titles with 0 copies remaining on the shelves. +

-
+
{items.length === 0 ? ( -

Zero inventory bottlenecks reported.

+

+ Zero inventory bottlenecks reported. +

) : ( items.map(item => ( -
- +
+ {item.name} - {/* ๐Ÿ’ก UPDATED: Changed from "Holds Pending" to a clear restock urgency level */} - - {item.requests > 1 ? `High Demand (0 Left)` : `0 Copies Available`} + + {item.requests > 1 ? `High Demand` : `Out of Stock`}
)) )}
- {/* Keep this ready for when we link the purchase order handler */} - + */}
); \ No newline at end of file diff --git a/client/src/features/dashboard/components/DeadStockWidget.tsx b/client/src/features/dashboard/components/DeadStockWidget.tsx index a42ba94..cd69973 100644 --- a/client/src/features/dashboard/components/DeadStockWidget.tsx +++ b/client/src/features/dashboard/components/DeadStockWidget.tsx @@ -1,27 +1,44 @@ -interface DeadBook { id: string; title: string; shelf: string } +// Editorial Visual Assets +import { Archive } from "lucide-react"; + +interface DeadBook { + id: string; + title: string; + shelf: string; +} export const DeadStockWidget = ({ items }: { items: DeadBook[] }) => ( -
+
-

๐Ÿ“ฆ Relocation "Dead Stock"

-

Inventory titles with zero checkouts over the past 6 months.

+

+ Relocation "Dead Stock" +

+

+ Inventory titles with zero checkouts over the past 6 months. +

-
+ +
{items.length === 0 ? ( -

All assets show healthy conversion rates.

+

+ All assets show healthy conversion rates. +

) : ( items.map(item => ( -
- {item.title} - +
+ + {item.title} + + Shelf: {item.shelf}
)) )}
- + */}
); \ No newline at end of file diff --git a/client/src/features/dashboard/components/EngagementLeaderboard.tsx b/client/src/features/dashboard/components/EngagementLeaderboard.tsx index 63b0cc2..54677d7 100644 --- a/client/src/features/dashboard/components/EngagementLeaderboard.tsx +++ b/client/src/features/dashboard/components/EngagementLeaderboard.tsx @@ -1,29 +1,47 @@ -interface TopUser { id: string; name: string; loans: number; onTimeRate: number } +// Editorial Visual Assets +import { Trophy } from "lucide-react"; + +interface TopUser { + id: string; + name: string; + loans: number; + onTimeRate: number; +} export const EngagementLeaderboard = ({ members }: { members: TopUser[] }) => ( -
+
-

๐ŸŽ–๏ธ Elite Reader Engagement

-

Top-performing student accounts returning assets on time.

+

+ Elite Reader Engagement +

+

+ Top-performing student accounts returning assets on time. +

-
+ +
{members.slice(0, 3).map((member, index) => ( -
+
- + #{index + 1} - {member.name} + + {member.name} +
-

{member.loans} Loans

-

{member.onTimeRate}% On-Time

+

{member.loans} Loans

+

+ {member.onTimeRate}% On-Time +

))}
-
- ๐ŸŽ‰ Run Automated Reward Incentives Applied -
+ + {/*
+ Automated Reward Incentives Applied +
*/}
); \ No newline at end of file diff --git a/client/src/features/dashboard/components/FineVelocityGauge.tsx b/client/src/features/dashboard/components/FineVelocityGauge.tsx index 69eca5f..93e7934 100644 --- a/client/src/features/dashboard/components/FineVelocityGauge.tsx +++ b/client/src/features/dashboard/components/FineVelocityGauge.tsx @@ -2,20 +2,42 @@ export const FineVelocityGauge = ({ collected, outstanding }: { collected: numbe const total = collected + outstanding || 1; const percentage = Math.round((collected / total) * 100); return ( -
+
-

๐Ÿ’ฐ Recovery Collection Velocity

-
-

โ‚น{collected} Recovered

-

Remaining Outstanding: โ‚น{outstanding}

+

+ Recovery Collection Velocity +

+
+

โ‚น{collected} Recovered

+

+ Remaining Outstanding: โ‚น{outstanding} +

-
+ + {/* Editorial Velocity Circular Gauge */} +
- - + {/* Base track channel layout */} + + {/* Reactive quantitative value progress track layer */} + - {percentage}% + {percentage}%
); diff --git a/client/src/features/dashboard/components/MetricsGrid.tsx b/client/src/features/dashboard/components/MetricsGrid.tsx index d53f69f..d82a2e7 100644 --- a/client/src/features/dashboard/components/MetricsGrid.tsx +++ b/client/src/features/dashboard/components/MetricsGrid.tsx @@ -1,21 +1,26 @@ import type { DashboardSummaryMetrics } from "../../../types/dashboard"; +// Editorial Visual Assets +import { BookOpen, BookCheck, Users, AlertTriangle, Coins } from "lucide-react"; + interface MetricCardProps { title: string; value: string | number; subtext?: string; bgClass: string; - icon: string; + icon: React.ReactNode; } const MetricCard = ({ title, value, subtext, bgClass, icon }: MetricCardProps) => ( -
-
- {title} -
{value}
- {subtext &&

{subtext}

} +
+
+ {title} +
{value}
+ {subtext &&

{subtext}

} +
+
+ {icon}
-
{icon}
); @@ -26,34 +31,34 @@ export const MetricsGrid = ({ data }: { data: DashboardSummaryMetrics | undefine title="Total Books" value={data?.totalBooks || 0} subtext={`Total copies: ${data?.totalCopies || 0}`} - bgClass="bg-blue-50 text-blue-600" - icon="๐Ÿ“˜" + bgClass="bg-amber-50/50 text-slate-900 border border-amber-100" + icon={} /> } /> } /> } /> } />
); diff --git a/client/src/features/dashboard/components/OverdueTable.tsx b/client/src/features/dashboard/components/OverdueTable.tsx index e6b7c73..745aafe 100644 --- a/client/src/features/dashboard/components/OverdueTable.tsx +++ b/client/src/features/dashboard/components/OverdueTable.tsx @@ -3,43 +3,45 @@ import type { OverdueRecord } from "../../../types/dashboard"; export const OverdueTable = ({ records }: { records: OverdueRecord[] | undefined }) => { if (!records || records.length === 0) { return ( -
-

System verification clear: No overdue inventory items currently registered.

+
+

+ System verification clear: No overdue inventory items currently registered. +

); } return ( -
- +
+
- - - - - - + + + + + + - + {records.map((row) => ( - - + - - - + - diff --git a/client/src/features/dashboard/components/PeakHoursChart.tsx b/client/src/features/dashboard/components/PeakHoursChart.tsx index 4c27b68..f9cb371 100644 --- a/client/src/features/dashboard/components/PeakHoursChart.tsx +++ b/client/src/features/dashboard/components/PeakHoursChart.tsx @@ -1,21 +1,37 @@ +// Editorial Visual Assets +import { TrendingUp } from "lucide-react"; + export const PeakHoursChart = ({ data }: { data: { day: string; count: number }[] }) => { const maxCount = Math.max(...data.map(d => d.count), 1); return ( -
+
-

๐Ÿ“ˆ Foot-Traffic Velocity

Weekly checkout frequencies by operational calendar days.

+

+ Foot-Traffic Velocity +

+

+ Weekly checkout frequencies by operational calendar days. +

-
+ + {/* Bar graph container track */} +
{data.map((item) => (
-
+ {/* Pop up data floating label indicator */} +
{item.count}
+ + {/* Interactive column block chart visualization */}
- {item.day} + + + {item.day} +
))}
diff --git a/client/src/features/dashboard/components/RetentionAnalytics.tsx b/client/src/features/dashboard/components/RetentionAnalytics.tsx index 107ae27..047258d 100644 --- a/client/src/features/dashboard/components/RetentionAnalytics.tsx +++ b/client/src/features/dashboard/components/RetentionAnalytics.tsx @@ -1,19 +1,38 @@ +// Editorial Visual Assets +import { Clock } from "lucide-react"; + export const RetentionAnalytics = ({ metrics }: { metrics?: { avgDays: number; threshold: number } }) => { const avg = metrics?.avgDays || 12.4; const maxLimit = metrics?.threshold || 14; return ( -
+
-

โณ Retention Lifetime Policy

-

Average reading span before returning an item.

+

+ Retention Lifetime Policy +

+

+ Average reading span before returning an item. +

-
-
{avg} Days
-

Average Book Lifecycle Span

+ + {/* Hero Analytics Counter Callout */} +
+
+ {avg} Days +
+

+ Average Book Lifecycle Span +

-
-

Current Hard Due Limit Threshold

-

{maxLimit} Days Authorized

+ + {/* Threshold Capacity Info block */} +
+

+ Current Hard Due Limit Threshold +

+

+ {maxLimit} Days Authorized +

); diff --git a/client/src/features/dashboard/components/ReturnForecaster.tsx b/client/src/features/dashboard/components/ReturnForecaster.tsx index 5be0035..5163227 100644 --- a/client/src/features/dashboard/components/ReturnForecaster.tsx +++ b/client/src/features/dashboard/components/ReturnForecaster.tsx @@ -1,22 +1,36 @@ +// Editorial Visual Assets +import { CalendarDays } from "lucide-react"; + export const ReturnForecaster = ({ forecast }: { forecast: { date: string; count: number }[] }) => { const maxForecast = Math.max(...forecast.map(f => f.count), 1); return ( -
+
-

๐Ÿ“… 7-Day Return Flow Forecaster

-

Expected book return volumes to optimize intake shelf arrangements.

+

+ 7-Day Return Flow Forecaster +

+

+ Expected book return volumes to optimize intake shelf arrangements. +

-
+ + {/* Horizontal Data Progress Bars Distribution layout */} +
{forecast.map((day) => ( -
- {day.date} -
+
+ {day.date} + + {/* Horizontal progress channel tracks */} +
- {day.count} items + + + {day.count} items +
))}
diff --git a/client/src/features/fines/components/DeleteFinesModal.tsx b/client/src/features/fines/components/DeleteFinesModal.tsx index ca99744..5cd7bc5 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 */} +
+ +
+ +

Delete Fine Record

+ +

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

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

+ Warning: This action will permanently remove this fine record from the history log. +

+
-
- - +
+
); diff --git a/client/src/features/fines/components/FineDetailsModal.tsx b/client/src/features/fines/components/FineDetailsModal.tsx index e4df35e..812560b 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

-
+

Book Title IdentifierBorrower Account ReferenceExpected Return DateDelay PeriodAccumulated Penalties
Book Title IdentifierBorrower Account ReferenceExpected Return DateDelay PeriodAccumulated Penalties
+
{row.title} - + + ID: {row.memberId} {row.borrowerName} {row.dueDate} - + {row.dueDate} + {row.daysLate} days overdue + โ‚น{row.fineAmount}
- - - - + + + + - + - - - + + + {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..6e2fd22 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

-

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

Overdue Fines Detected

+

+ There are currently {totalCount} active overdue items requiring immediate attention.

-
-
- Uncollected Portfolio - โ‚น{totalUnpaidAmount.toLocaleString()}.00 + +
+
+ Total Outstanding Balance + โ‚น{totalUnpaidAmount.toLocaleString()}.00
- +
); diff --git a/client/src/features/fines/components/RestoreFineModal.tsx b/client/src/features/fines/components/RestoreFineModal.tsx index a24d29b..4b11333 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 Fine Record

+

+ Are you sure you want to revert the payment transaction for "{fine.memberName}"? This will shift the record back to the active overdue list.

-
- + +
+ diff --git a/client/src/features/fines/components/SettleFinePaymentModal.tsx b/client/src/features/fines/components/SettleFinePaymentModal.tsx index 3094274..ef5d61a 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,51 +6,83 @@ 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 +
+ Balance Due: + โ‚น{fine.fine_amount}.00 +
+
+ + {/* Payment Method Selector Segment */} +
+ +
+ {(["CASH", "CARD", "UPI"] as const).map((method) => ( + + ))}
{/* Payment Calendar Target Input */} -
-