From e3f61e6f90085d15cbeaa71265aab0de7bfc4fde Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Fri, 12 Jun 2026 16:29:07 +0530 Subject: [PATCH 01/11] fix: Imporve the Overall UI --- .../src/features/categories/pages/ManageCategories.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/features/categories/pages/ManageCategories.tsx b/client/src/features/categories/pages/ManageCategories.tsx index 3b6c1a6..caa38aa 100644 --- a/client/src/features/categories/pages/ManageCategories.tsx +++ b/client/src/features/categories/pages/ManageCategories.tsx @@ -274,9 +274,9 @@ export const ManageCategories = () => { }} className="w-full pl-4 pr-10 py-2 bg-slate-50 border border-border-main text-slate-800 rounded-xl text-xs font-bold uppercase tracking-wider appearance-none outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all cursor-pointer" > - - - + + + @@ -297,8 +297,8 @@ export const ManageCategories = () => { className="w-full pl-4 pr-10 py-2 bg-slate-50 border border-border-main text-slate-800 rounded-xl text-xs font-bold uppercase tracking-wider appearance-none outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all cursor-pointer" > - - + + From caf9808a0144b07e7f1b842ebf6903fcb459ea9f Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Sat, 13 Jun 2026 14:02:07 +0530 Subject: [PATCH 02/11] fix: Add some business logic in already available modules --- .../books/components/BookDetailModal.tsx | 21 ++- .../features/books/components/BookModal.tsx | 22 ++- client/src/features/books/pages/BooksPage.tsx | 61 ++++++- .../src/features/books/schemas/bookSchema.ts | 1 + .../fines/components/FineDetailsModal.tsx | 2 +- client/src/types/books.ts | 6 + server/src/database/models/Book.ts | 7 + server/src/modules/books/book.controller.ts | 13 ++ server/src/modules/books/book.repository.ts | 110 ++++++++---- server/src/modules/books/book.routes.ts | 7 + server/src/modules/books/book.service.ts | 4 + server/src/modules/books/book.types.ts | 5 +- server/src/modules/books/book.validation.ts | 4 +- .../modules/dashboard/dashboard.controller.ts | 5 + .../modules/dashboard/dashboard.repository.ts | 106 ++++------- server/src/modules/fines/fine.service.ts | 168 +++++++++++++++--- server/src/modules/issues/issue.service.ts | 35 +++- server/src/modules/plans/plans.service.ts | 51 +++++- 18 files changed, 466 insertions(+), 162 deletions(-) diff --git a/client/src/features/books/components/BookDetailModal.tsx b/client/src/features/books/components/BookDetailModal.tsx index a909e30..9ecaa10 100644 --- a/client/src/features/books/components/BookDetailModal.tsx +++ b/client/src/features/books/components/BookDetailModal.tsx @@ -54,7 +54,7 @@ export const BookDetailModal = ({ return ( <>
-
+
{/* Header section */}
@@ -77,11 +77,12 @@ export const BookDetailModal = ({ {/* Detailed Metadata Grid */}
+
-
+
{book.title}
@@ -90,17 +91,27 @@ export const BookDetailModal = ({ -
+
{book.author}
+
+ +
+ +
+ {book.language} +
+
-
+
{book.categoryName}
@@ -108,7 +119,7 @@ export const BookDetailModal = ({ -
+
{shelfEntryDate}
diff --git a/client/src/features/books/components/BookModal.tsx b/client/src/features/books/components/BookModal.tsx index 6080711..748a1cf 100644 --- a/client/src/features/books/components/BookModal.tsx +++ b/client/src/features/books/components/BookModal.tsx @@ -47,7 +47,7 @@ export const BookModal = ({ formState: { errors }, } = useForm({ resolver: zodResolver(BookFormSchema), - defaultValues: { title: "", author: "", totalCopies: 1, categoryId: "" }, + defaultValues: { title: "", author: "", language:"", totalCopies: 1, categoryId: "" }, }); // Simple synchronization cycle without cascading state updates @@ -56,17 +56,19 @@ export const BookModal = ({ reset({ title: editingBook.title, author: editingBook.author, + language: editingBook.language, totalCopies: editingBook.totalCopies, categoryId: editingBook.categoryId, }); } else { - reset({ title: "", author: "", totalCopies: 1, categoryId: "" }); + reset({ title: "", author: "", language:"", totalCopies: 1, categoryId: "" }); } }, [editingBook, reset]); // Safely cleans state artifacts within action contexts outside of reactive render loops const handleCloseModal = () => { setOcrAlternatives([]); + reset(); onClose(); }; @@ -213,6 +215,22 @@ export const BookModal = ({ )}
+
+ + + {errors.author && ( +

+ {errors.author.message} +

+ )} +
+
+ + + {book.language} + + diff --git a/client/src/features/books/schemas/bookSchema.ts b/client/src/features/books/schemas/bookSchema.ts index 90be0f2..8cafcfd 100644 --- a/client/src/features/books/schemas/bookSchema.ts +++ b/client/src/features/books/schemas/bookSchema.ts @@ -7,6 +7,7 @@ export const BookFormSchema = z.object({ .int({ message: "Stock counts must be whole integers" }) .min(1, { message: "Minimum catalog collection entry requires 1 copy" }), categoryId: z.string().min(1, { message: "Please map this asset to an organizational category" }), + language: z.string().min(1, { message: "language is required" }), }); export type BookFormValues = z.infer; \ No newline at end of file diff --git a/client/src/features/fines/components/FineDetailsModal.tsx b/client/src/features/fines/components/FineDetailsModal.tsx index 7a1e78d..747f375 100644 --- a/client/src/features/fines/components/FineDetailsModal.tsx +++ b/client/src/features/fines/components/FineDetailsModal.tsx @@ -103,7 +103,7 @@ export const FineDetailsModal = ({

{" "} - Circulated Media Target + Book Details

diff --git a/client/src/types/books.ts b/client/src/types/books.ts index 63f8b92..71ab3fa 100644 --- a/client/src/types/books.ts +++ b/client/src/types/books.ts @@ -3,12 +3,18 @@ export interface BookCategory { name: string; } +export interface LanguageCategory { + id: string; + name: string; +} + export interface BookInventoryItem { id: string; title: string; author: string; totalCopies: number; availableCopies: number; + language: string; lendingCount: number; categoryId: string; categoryName: string; diff --git a/server/src/database/models/Book.ts b/server/src/database/models/Book.ts index e4cd99a..4d8ba44 100644 --- a/server/src/database/models/Book.ts +++ b/server/src/database/models/Book.ts @@ -24,6 +24,7 @@ class Book extends Model< declare available_copies:number; + declare language: CreationOptional; declare lending_count: CreationOptional; // Defaults to 0 in database declare readonly created_at: CreationOptional; // Handled by timestamps declare readonly updated_at: CreationOptional; @@ -67,6 +68,12 @@ Book.init( defaultValue: 0, }, + language: { + type: DataTypes.STRING(100), + allowNull: true, // Kept true for migration safety, managed by defaults + defaultValue: "English", + }, + created_at: { type: DataTypes.DATE, }, diff --git a/server/src/modules/books/book.controller.ts b/server/src/modules/books/book.controller.ts index c2ca08f..917e911 100644 --- a/server/src/modules/books/book.controller.ts +++ b/server/src/modules/books/book.controller.ts @@ -100,6 +100,19 @@ export const getCategoriesController = asyncHandler( } ); +export const getLanguagesController = asyncHandler( + async (req: Request, res: Response) => { + const result = await bookService.getLanguages(); + + sendResponse(res, { + success: true, + statusCode: 200, + message: "Languages fetched successfully", + data: result, + }); + } +); + export const searchBooksController = asyncHandler(async (req: Request, res: Response) => { // 💡 FIX (Line 84): Cast req.query.q cleanly into a string primitive fallback const searchString = String(req.query.q || ""); diff --git a/server/src/modules/books/book.repository.ts b/server/src/modules/books/book.repository.ts index 33a362e..549c7b2 100644 --- a/server/src/modules/books/book.repository.ts +++ b/server/src/modules/books/book.repository.ts @@ -1,6 +1,4 @@ -import { Op, CreationAttributes } from "sequelize"; - -import Book from "../../database/models/Book.js"; +import { fn, col, Op, CreationAttributes } from "sequelize";import Book from "../../database/models/Book.js"; import Category from "../../database/models/Category.js"; import { @@ -15,8 +13,8 @@ class BookRepository { book_author: payload.book_author, category_id: payload.category_id, total_copies: payload.total_copies, - // 💡 Now perfectly legal since we expanded the interface contract type! available_copies: payload.available_copies ?? payload.total_copies, + language: payload.language } as CreationAttributes); } @@ -24,42 +22,62 @@ class BookRepository { * 💡 Handles clean pagination limits, offsets, mixed title/author searches, * and isolated category ID relational filtering. */ - async getBooks( - page: number, - limit: number, - search?: string, - category_id?: string - ) { - const offset = (page - 1) * limit; - return Book.findAndCountAll({ - where: { - ...(search && { - [Op.or]: [ - { book_name: { [Op.iLike]: `%${search}%` } }, - { book_author: { [Op.iLike]: `%${search}%` } }, - ], - }), - ...(category_id && { category_id }), - }, - include: [ - { - model: Category, - as: "category", - attributes: [ - ["category_id", "id"], - ["category_name", "name"] - ], - }, - ], +async getBooks( + page: number, + limit: number, + search?: string, + category_id?: string, + language?: string +) { + const offset = (page - 1) * limit; + + // 1. Build an isolated, strong where clause object literal + const whereClause: any = {}; + + // Handle fuzzy title/author searches + if (search) { + whereClause[Op.or] = [ + { book_name: { [Op.iLike]: `%${search}%` } }, + { book_author: { [Op.iLike]: `%${search}%` } }, + ]; + } - limit, - offset, - order: [["created_at", "DESC"]], - }); + // Handle direct relational category identifiers + if (category_id) { + whereClause.category_id = category_id; + } + + // 🌟 FIXED: Explicit strict lowercase validation to safeguard string matches + if (language) { + whereClause.language = { + [Op.iLike]: language.trim() // Protects against hidden spaces or casing mismatches (e.g., "hindi" vs "Hindi") + }; } + // 2. Fire structured finder query payload + return Book.findAndCountAll({ + where: whereClause, + include: [ + { + model: Category, + as: "category", + attributes: [ + ["category_id", "id"], + ["category_name", "name"] + ], + required: false // Keeps books visible even if their category isn't loaded + }, + ], + limit, + offset, + order: [["created_at", "DESC"]], + // 🌟 CRITICAL FOR PAGINATION WITH INNER JOINS: + // Prevents duplicate row counts and keeps where clauses locked to the parent table! + distinct: true, + }); +} async getBookById(book_id: string) { return Book.findByPk(book_id, { include: [ @@ -75,13 +93,13 @@ class BookRepository { book_id: string, payload: UpdateBookPayload ) { - // 💡 FIX: Safe, explicit assignments prevent accidental column drops on PATCH updates await Book.update({ ...(payload.book_name && { book_name: payload.book_name }), ...(payload.book_author && { book_author: payload.book_author }), ...(payload.category_id && { category_id: payload.category_id }), ...(payload.total_copies !== undefined && { total_copies: payload.total_copies }), ...(payload.available_copies !== undefined && { available_copies: payload.available_copies }), + ...(payload.language && { language: payload.language }) }, { where: { book_id }, }); @@ -103,7 +121,7 @@ class BookRepository { { book_author: { [Op.iLike]: `%${searchToken}%` } } ] }, - attributes: ["book_id", "book_name", "book_author", "available_copies"], + attributes: ["book_id", "book_name", "book_author", "available_copies", "language"], order: [["book_name", "ASC"]], limit: 15 }); @@ -116,6 +134,7 @@ class BookRepository { book_id: book.book_id, title: book.book_name, author: book.book_author || "Unknown Author", + language: book.language || "Not Mentioned", available_copies: stockCount, compliance: { status: outOfStock ? "OUT_OF_STOCK" : "AVAILABLE", @@ -140,6 +159,23 @@ class BookRepository { order: [["category_name", "ASC"]], }); } + + async getLanguages() { + const records = await Book.findAll({ + attributes: [ + [fn("DISTINCT", col("language")), "language"] + ], + where: { + language: { + [Op.not]: null as any + } + }, + raw: true, + order: [["language", "ASC"]], + }); + + return records.map((r: any) => r.language); +} } export default new BookRepository(); \ No newline at end of file diff --git a/server/src/modules/books/book.routes.ts b/server/src/modules/books/book.routes.ts index 52f97a4..65c4357 100644 --- a/server/src/modules/books/book.routes.ts +++ b/server/src/modules/books/book.routes.ts @@ -251,6 +251,7 @@ import { searchBooksController, updateBookController, getCategoriesController, + getLanguagesController } from "./book.controller.js"; import { @@ -270,6 +271,12 @@ router.get( getCategoriesController ); +router.get( + "/languages", + auth, + getLanguagesController +); + router.get( "/search", auth, diff --git a/server/src/modules/books/book.service.ts b/server/src/modules/books/book.service.ts index 92d1442..90db658 100644 --- a/server/src/modules/books/book.service.ts +++ b/server/src/modules/books/book.service.ts @@ -84,6 +84,10 @@ class BookService { async getCategories() { return bookRepository.getCategories(); } + + async getLanguages() { + return bookRepository.getLanguages(); + } } export default new BookService(); \ No newline at end of file diff --git a/server/src/modules/books/book.types.ts b/server/src/modules/books/book.types.ts index 4874665..a1d6369 100644 --- a/server/src/modules/books/book.types.ts +++ b/server/src/modules/books/book.types.ts @@ -9,6 +9,7 @@ export interface SearchBookResult { message: string; isBlocked: boolean; // true means dropdown blocks click selection (0 copies) }; + language: string | "English"; } export interface CreateBookPayload { @@ -16,7 +17,8 @@ export interface CreateBookPayload { book_author: string; category_id: string; total_copies: number; - available_copies?: number; // 💡 Added as an optional property + available_copies?: number; + language: string | "English"; } export interface UpdateBookPayload { @@ -25,4 +27,5 @@ export interface UpdateBookPayload { category_id?: string; total_copies?: number; available_copies?: number; + language?: string | "English"; } \ No newline at end of file diff --git a/server/src/modules/books/book.validation.ts b/server/src/modules/books/book.validation.ts index 5848115..52e8ae7 100644 --- a/server/src/modules/books/book.validation.ts +++ b/server/src/modules/books/book.validation.ts @@ -16,6 +16,8 @@ export const createBookSchema = z.object({ total_copies: z.coerce .number() .min(1, "Total copies must be at least 1"), + + langauge: z.string().min(2, "langauge name atleast contain 2 charcaters") }), }); @@ -24,9 +26,9 @@ export const updateBookSchema = z.object({ book_name: z.string().optional(), book_author: z.string().optional(), category_id: z.uuid().optional(), - // 💡 FIX: Coerce numbers here as well for updates total_copies: z.coerce.number().optional(), available_copies: z.coerce.number().optional(), + langauge: z.string().optional() }), }); diff --git a/server/src/modules/dashboard/dashboard.controller.ts b/server/src/modules/dashboard/dashboard.controller.ts index 776df4e..11aeec7 100644 --- a/server/src/modules/dashboard/dashboard.controller.ts +++ b/server/src/modules/dashboard/dashboard.controller.ts @@ -15,6 +15,11 @@ export const getDashboardSummaryController = asyncHandler( // 💡 Executing the centralized class instance method const result: CompleteDashboardSummaryResponse = await dashboardService.getDashboardSummaryService(); + console.log("=== 🛰️ BACKEND DASHBOARD SUMMARY PAYLOAD ==="); + console.log("Total Books (Titles Count):", result.summary.totalBooks); + console.log("Total Copies (Physical Sum):", result.summary.totalCopies); + console.log("Available Books (Shelf Sum):", result.summary.availableBooks); + console.log("============================================"); // Return formatted using JSend convention format to feed TanStack query smoothly return res.status(200).json({ success: true, diff --git a/server/src/modules/dashboard/dashboard.repository.ts b/server/src/modules/dashboard/dashboard.repository.ts index 782e40d..b6c7959 100644 --- a/server/src/modules/dashboard/dashboard.repository.ts +++ b/server/src/modules/dashboard/dashboard.repository.ts @@ -13,6 +13,8 @@ class DashboardRepository { */ async getOverview() { const [ + totalCopies, // 🌟 FIXED: Swapped out findOne for explicit column sum + availableCopies, // 🌟 FIXED: Swapped out findOne for explicit column sum totalBooks, totalMembers, activeMembers, @@ -22,16 +24,14 @@ class DashboardRepository { overdueCount, fineAggregationResult, ] = await Promise.all([ - Book.count(), + Book.sum("total_copies"), + Book.sum("available_copies"), + Book.count(), Member.count(), Member.count({ where: { membership_status: "ACTIVE" } }), Member.count({ where: { membership_status: "EXPIRED" } }), - Issue.count(), - Issue.count({ - where: { - returned_date: { [Op.not]: null }, - }, - }), + Issue.count({ where: { returned_date: null } }), + Issue.count({ where: { returned_date: { [Op.not]: null } } }), Issue.count({ where: { returned_date: null, @@ -39,28 +39,23 @@ class DashboardRepository { }, }), Fine.findOne({ - attributes: [ - [ - fn("COALESCE", fn("SUM", col("fine_amount")), 0), - "total_unpaid" - ] - ], + attributes: [[fn("COALESCE", fn("SUM", col("fine_amount")), 0), "total_unpaid"]], where: { paid_status: false }, raw: true, }), ]); - const unpaidFines = fineAggregationResult - ? Number((fineAggregationResult as any).total_unpaid) - : 0; + const unpaidFines = fineAggregationResult ? Number((fineAggregationResult as any).total_unpaid) : 0; return { - totalBooks, + totalBooks, + totalCopies: Number(totalCopies || 0), + availableCopies: Number(availableCopies || 0), totalMembers, activeMembers, expiredMembers, - issuedBooks, - returnedBooks, + issuedBooks, + returnedBooks, overdueCount, unpaidFines, }; @@ -70,18 +65,20 @@ class DashboardRepository { const today = new Date(); // ========================================================================= - // 1. CONCURRENT ROOT COUNT AGGREGATIONS + // 1. CONCURRENT ROOT COUNT AGGREGATIONS (FIXED & ALIGNED) // ========================================================================= const [ + totalCopiesCount, // 🌟 FIXED: Direct mathematical sum + availableBooksCount, // 🌟 FIXED: Direct mathematical sum totalBooksCount, - totalCopiesCount, activeMembersCount, issuedBooksCount, fineAggregationResult, recoveredFinesResult ] = await Promise.all([ + Book.sum("total_copies"), + Book.sum("available_copies"), Book.count(), - Book.sum("available_copies").then(sum => sum || 0), Member.count({ where: { membership_status: "ACTIVE" } }), Issue.count({ where: { returned_date: null } }), Fine.findOne({ @@ -96,8 +93,6 @@ class DashboardRepository { }) ]); - // The remainder of your calculations now receive a accurate inventory count anchor - const availableBooksCount = Math.max(0, totalCopiesCount - issuedBooksCount); const totalFinesAgg = fineAggregationResult ? Number((fineAggregationResult as any).total_unpaid) : 0; const collectedFinesAgg = recoveredFinesResult ? Number((recoveredFinesResult as any).total_recovered) : 0; @@ -147,9 +142,6 @@ class DashboardRepository { // ========================================================================= // 3. WIDGET INTELLIGENCE ENGINE QUERIES // ========================================================================= - - // Idea 1: Peak Operational Foot-Traffic Distribution (Last 30 Days) - // Idea 1: Peak Operational Foot-Traffic Distribution (Last 30 Days) const formattedDateCol = fn("TO_CHAR", col("created_at"), "Dy"); const rawPeakHours = await Issue.findAll({ @@ -160,47 +152,33 @@ class DashboardRepository { where: { created_at: { [Op.gte]: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } }, - // 💡 FIXED: Group by the functional column structure definition directly to avoid the type error group: [formattedDateCol as any], raw: true }); - const dayOrderMap: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; const weekDays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const peakHoursFormatted = weekDays.map(d => { const found = (rawPeakHours as any[]).find(r => r.day_of_week?.trim() === d); return { day: d, count: found ? Number(found.checkout_count) : 0 }; }); - // ========================================================================= - // IDEA 2: AUTOMATED CRITICAL DEFICIT PROCUREMENT PREDICTOR (ULTRA-SIMPLE) - // ========================================================================= - // 1. Directly fetch books where available stock has dropped to zero const outOfStockBooks = await Book.findAll({ attributes: ["book_id", "book_name", "total_copies", "available_copies"], - where: { - // 💡 Look for books with absolutely zero copies left on the shelf - available_copies: 0 - }, - order: [["lending_count", "DESC"]], // Prioritize books that are historically very popular + where: { available_copies: 0 }, + order: [["lending_count", "DESC"]], limit: 5, raw: true }); - // 2. Map the results directly to the frontend contract format const criticalDeficit = (outOfStockBooks as any[]).map((b) => { const totalCopiesOwned = Number(b.total_copies || 1); - return { id: String(b.book_id || b.id), name: String(b.book_name || "Unknown Asset Title"), - // 💡 Mathematically calculate a demand score based on how large the total collection size is requests: Math.max(1, Math.floor(totalCopiesOwned * 1.5)) }; }); - // 3. Fallback Guard: If the library is perfectly stocked (0 books have an available count of 0) - // Show the top 5 most heavily read books as a safe dashboard placeholder grid if (criticalDeficit.length === 0) { const topFivePopular = await Book.findAll({ attributes: ["book_id", "book_name"], @@ -212,12 +190,11 @@ class DashboardRepository { criticalDeficit.push(...(topFivePopular as any[]).map(b => ({ id: String(b.book_id || b.id), name: String(b.book_name), - requests: 1 // Baseline placeholder value + requests: 1 }))); } - // Idea 5: Dead Stock Inventory Relocation Predictor - const deadStockRows = await Book.findAll({ + const deadStockRows = await Book.findAll({ where: { [Op.or]: [ { lending_count: 0 }, @@ -231,25 +208,15 @@ class DashboardRepository { const shelves = ["A-1", "B-4", "C-2", "D-1", "E-3"]; const deadStock = deadStockRows.map((b: any, index) => { const assignedShelf = shelves[index % shelves.length]; - return { id: String(b.book_id || b.id), title: String(b.book_name || "Dormant Inventory Book"), - // 💡 FIXED: Providing a guaranteed default string fallback clears the "string | undefined" error! shelf: assignedShelf || "A-1" }; }); - - // ========================================================================= - // Idea 7: Category Popularity Distribution Breakdown (REAL-TIME DB QUERIES) - // ========================================================================= - // 1. Predefined sequential dashboard brand colors for your top 4 slots const brandColors = ["bg-teal-500", "bg-blue-500", "bg-indigo-500", "bg-amber-500"]; - // 2. Query the category table to get the top 4 categories ordered by book volume - // Note: Assuming your Category model is imported/available in this scope. - // If it's named differently, adjust the model reference name accordingly. const dbCategoryPopularity = await Category.findAll({ attributes: [ "category_id", @@ -259,9 +226,9 @@ class DashboardRepository { include: [ { model: Book, - as: "books", // Matches your existing category-to-books association alias + as: "books", attributes: [], - required: false // Keeps categories visible even if they have 0 volumes setup + required: false } ], group: ["Category.category_id", "Category.category_name"], @@ -271,14 +238,12 @@ class DashboardRepository { raw: true }); - // 3. Map database raw records directly into your existing frontend contract schema const categoryPopularity = dbCategoryPopularity.map((cat: any, index: number) => ({ name: String(cat.category_name || "Uncategorized"), value: Number(cat.booksCount) || 0, color: brandColors[index] || brandColors[brandColors.length - 1] })); - // 4. Fallback Guard: If your library database tables have 0 categories populated yet if (categoryPopularity.length === 0) { categoryPopularity.push( { name: "Engineering", value: 0, color: "bg-teal-500" }, @@ -288,12 +253,10 @@ class DashboardRepository { ); } - // Idea 8: 7-Day Return Flow Forecaster const returnForecast = Array.from({ length: 7 }).map((_, i) => { const futureDate = new Date(); futureDate.setDate(today.getDate() + i); const dayStr = futureDate.toLocaleDateString("en-US", { weekday: "short" }); - // Calculate realistic forecast models using basic modulo variations const simulatedDailyCount = Math.max(1, (overdueCount + i) % 6); return { date: dayStr, @@ -301,7 +264,6 @@ class DashboardRepository { }; }); - // Idea 9: Elite Member Engagement Leaderboard const topBorrowers = await Issue.findAll({ attributes: [ "member_id", @@ -310,7 +272,7 @@ class DashboardRepository { group: [ "Issue.member_id", "member.member_id", - "member->user.uuid", // If your User model uses 'id', change this to "member->user.id" + "member->user.uuid", "member->user.name" ], order: [[literal("total_loans"), "DESC"]], @@ -326,23 +288,22 @@ class DashboardRepository { id: String(b.member_id), name: String(b.member?.user?.name || "Premium Reader"), loans: Number(b.dataValues.total_loans || 1), - onTimeRate: 94 // Base score for elite reader metrics + onTimeRate: 94 })); - // Idea 10: Retention Policy Lifecycle Metric const retentionMetrics = { - avgDays: 11.4, // Real-time calculation can be handled if return_date is fully parsed - threshold: 14 // Authorized loan period matching library guidelines + avgDays: 11.4, + threshold: 14 }; // ========================================================================= - // 4. UNIFIED CONSOLIDATED DATA OUTPUT HANDOFF + // 4. UNIFIED CONSOLIDATED DATA OUTPUT HANDOFF (FIXED MAP) // ========================================================================= return { summary: { - totalBooks: totalBooksCount, - totalCopies: totalCopiesCount, - availableBooks: availableBooksCount, + totalBooks: totalBooksCount, + totalCopies: Number(totalCopiesCount || 0), // 🌟 FIXED + availableBooks: Number(availableBooksCount || 0),// 🌟 FIXED activeMembers: activeMembersCount, overdueCount, overduePercentage, @@ -407,5 +368,4 @@ class DashboardRepository { } } -// Export instance as default export default new DashboardRepository(); \ No newline at end of file diff --git a/server/src/modules/fines/fine.service.ts b/server/src/modules/fines/fine.service.ts index cacd008..747d116 100644 --- a/server/src/modules/fines/fine.service.ts +++ b/server/src/modules/fines/fine.service.ts @@ -3,6 +3,7 @@ import AppError from "../../utils/AppError.js"; import fineRepository from "./fine.repository.js"; import { sequelize } from "../../database/index.js"; import Issue from "../../database/models/Issue.js"; +import Book from "../../database/models/Book.js"; import Member from "../../database/models/Member.js"; import MembershipPlan from "../../database/models/MembershipPlan.js"; import Fine from "../../database/models/Fine.js"; @@ -107,28 +108,83 @@ class FineService { } } + async payFine(fine_id: string, paidDate: string | Date | null, paymentMethod: "CASH" | "CARD" | "UPI") { - const fine = await fineRepository.getFineById(fine_id); + const fine = await fineRepository.getFineById(fine_id); - if (!fine) { - throw new AppError("Fine registry record not found", httpStatus.NOT_FOUND); - } + if (!fine) { + throw new AppError("Fine registry record not found", httpStatus.NOT_FOUND); + } - if (fine.paid_status) { - throw new AppError("This fine has already been settled", httpStatus.BAD_REQUEST); - } + if (fine.paid_status) { + throw new AppError("This fine has already been settled", httpStatus.BAD_REQUEST); + } - const validatedExecutionDate = paidDate ? new Date(paidDate) : new Date(); + const validatedExecutionDate = paidDate ? new Date(paidDate) : new Date(); - const paymentUpdates = { - paid_status: true, - paid_date: validatedExecutionDate, - payment_method: paymentMethod - }; + const paymentUpdates = { + paid_status: true, + paid_date: validatedExecutionDate, + payment_method: paymentMethod + }; + + // 🌟 START ACID TRANSACTION ATOMIC SHIELD + const transaction = await sequelize.transaction(); + + try { + // 1. ✅ FIXED: Run the fine update directly via Fine model to keep TypeScript happy with the transaction context + await Fine.update(paymentUpdates, { + where: { fine_id }, // Check if your fine primary key is named fine_id or id + transaction + }); + + // 2. Fetch the fresh state of the fine record to ensure synchronization + const updatedRecord = await fineRepository.getFineById(fine_id); - const updatedRecord = await fineRepository.payFine(fine_id, paymentUpdates); - return this.transformFineRecord(updatedRecord); + // 3. Cascade update the Issue ledger records if an issue_id is attached + if (fine.issue_id) { + // Look up using your explicit primary key: issue_id + const associatedIssue = await Issue.findByPk(fine.issue_id, { transaction }); + + // ✅ FIXED: Using 'issue_status' instead of 'status' to match your model declaration + if (associatedIssue && associatedIssue.issue_status === "OVERDUE") { + + // A. Automatically update status from OVERDUE to RETURNED + await Issue.update( + { + issue_status: "RETURNED", // ✅ FIXED + returned_date: validatedExecutionDate + }, + { + where: { issue_id: fine.issue_id }, // ✅ FIXED: Explicitly referencing the model primary key + transaction + } + ); + + // B. Increment the stock of copies available on library shelves + if (associatedIssue.book_id) { + await Book.increment( + { available_copies: 1 }, + { + where: { book_id: associatedIssue.book_id }, + transaction + } + ); + } + } + } + + // Commit changes safely to the database + await transaction.commit(); + + return this.transformFineRecord(updatedRecord!); + + } catch (error) { + // Roll back if any database error occurs + await transaction.rollback(); + throw error; } +} async purgeFine(fine_id: string) { if (!fine_id) { @@ -147,24 +203,78 @@ class FineService { } } - async restoreFine(fine_id: string) { - if (!fine_id) { - throw new AppError("Fine unique identifier parameter is missing", httpStatus.BAD_REQUEST); - } + async restoreFine(fine_id: string) { + if (!fine_id) { + throw new AppError("Fine unique identifier parameter is missing", httpStatus.BAD_REQUEST); + } - const fine = await fineRepository.getFineById(fine_id); - if (!fine) { - throw new AppError("Fine registry record not found for restoration", httpStatus.NOT_FOUND); - } + const fine = await fineRepository.getFineById(fine_id); + if (!fine) { + throw new AppError("Fine registry record not found for restoration", httpStatus.NOT_FOUND); + } - try { - await fineRepository.restoreFine(fine_id); - const updatedRecord = await fineRepository.getFineById(fine_id); - return this.transformFineRecord(updatedRecord); - } catch (error: any) { - throw new AppError(`Ledger Restoration Error: ${error.message}`, httpStatus.INTERNAL_SERVER_ERROR); + // 🌟 ACID TRANSACTION SHIELD: If the book count decrement fails, the fine doesn't change + const transaction = await sequelize.transaction(); + + try { + // 1. Reset the Fine metrics natively using the Fine model inside the transaction + await Fine.update( + { + paid_status: false, + paid_date: null, + payment_method: null + }, + { + where: { fine_id }, + transaction + } + ); + + // 2. Locate the linked Issue tracking record + if (fine.issue_id) { + const associatedIssue = await Issue.findByPk(fine.issue_id, { transaction }); + + // We only reverse it if the current status is marked as RETURNED + if (associatedIssue && associatedIssue.issue_status === "RETURNED") { + + // A. REVERSE STATUS: Shift from RETURNED back to OVERDUE and wipe out the returned_date timestamp + await Issue.update( + { + issue_status: "OVERDUE", + returned_date: null // Book is no longer officially returned + }, + { + where: { issue_id: fine.issue_id }, + transaction + } + ); + + // B. CORRECT SHELF STOCK: Decrement the copies back down by 1 since the book is still checked out + if (associatedIssue.book_id) { + await Book.decrement( + { available_copies: 1 }, + { + where: { book_id: associatedIssue.book_id }, + transaction + } + ); + } + } } + + // Commit all changes simultaneously + await transaction.commit(); + + // Fetch and transform the fresh record state to send back to your frontend template + const updatedRecord = await fineRepository.getFineById(fine_id); + return this.transformFineRecord(updatedRecord!); + + } catch (error: any) { + // Roll back changes cleanly if a database constraint locks up + await transaction.rollback(); + throw new AppError(`Ledger Restoration Error: ${error.message}`, httpStatus.INTERNAL_SERVER_ERROR); } +} /** * ⏰ DUAL-TRIGGER FINE ACCRUAL SYNC ENGINE diff --git a/server/src/modules/issues/issue.service.ts b/server/src/modules/issues/issue.service.ts index 32556ba..0313314 100644 --- a/server/src/modules/issues/issue.service.ts +++ b/server/src/modules/issues/issue.service.ts @@ -203,7 +203,6 @@ class IssueService { return issueRepository.getMemberIssues(member_id); } - // 4. Update Settings (Includes Robust Integrated Return Undo Framework Engine) async updateIssueParameters( issue_id: string, payload: { @@ -225,20 +224,18 @@ class IssueService { /* 🔄 DUAL-COMPLIANT REVERT RETURN LOGIC ACTIVATION */ /* -------------------------------------------------------------------------- */ if (issue.issue_status === "RETURNED" && payload.status === "BORROWED") { - const book = await Book.findByPk(issue.book_id, { lock: t.LOCK.UPDATE, transaction: t }); + const bookIdToUse = payload.bookId || issue.book_id; + const book = await Book.findByPk(bookIdToUse, { lock: t.LOCK.UPDATE, transaction: t }); if (!book) throw new AppError("Associated book inventory asset missing.", httpStatus.NOT_FOUND); - // Decrement available shelf inventory because the book is moving back out of the library if (book.available_copies <= 0) { throw new AppError("Cannot undo! This book's shelf slot is fully allocated right now.", httpStatus.BAD_REQUEST); } await book.decrement("available_copies", { by: 1, transaction: t }); - // 🧠 Dynamically calculate corrected status based on target date vs today const targetDueDate = payload.dueDate ? new Date(payload.dueDate) : new Date(issue.due_date); const dynamicRestoredStatus = new Date() > targetDueDate ? "OVERDUE" : "BORROWED"; - // 🟢 FIX: Update fine row instead of running Fine.destroy() const existingFine = await Fine.findOne({ where: { issue_id }, transaction: t }); if (existingFine) { await existingFine.update({ @@ -250,11 +247,11 @@ class IssueService { return await issueRepository.updateIssue(issue_id, { member_id: payload.memberId || issue.member_id, - book_id: payload.bookId || issue.book_id, + book_id: bookIdToUse, borrowed_date: payload.borrowDate ? new Date(payload.borrowDate) : issue.borrowed_date, due_date: targetDueDate, issue_status: dynamicRestoredStatus, - returned_date: null // Wipe timestamp marker row + returned_date: null }, { transaction: t }); } @@ -262,10 +259,30 @@ class IssueService { throw new AppError("Cannot change data parameters of a closed transactional history log.", httpStatus.BAD_REQUEST); } - // 📈 DYNAMIC EXTENSION STATE HANDLING + /* -------------------------------------------------------------------------- */ + /* 🔄 🟢 FIX: HANDLE BOOK TITLES SWAPPING ON ACTIVE COPIES */ + /* -------------------------------------------------------------------------- */ + if (!issue.returned_date && payload.bookId && payload.bookId !== issue.book_id) { + // 1. Give back the stock slot to the old book title + const oldBook = await Book.findByPk(issue.book_id, { lock: t.LOCK.UPDATE, transaction: t }); + if (oldBook) { + await oldBook.increment("available_copies", { by: 1, transaction: t }); + await oldBook.decrement("lending_count", { by: 1, transaction: t }); + } + + // 2. Claim a stock slot out from the brand new target book title + const newBook = await Book.findByPk(payload.bookId, { lock: t.LOCK.UPDATE, transaction: t }); + if (!newBook) throw new AppError("New target book title selection not found.", httpStatus.NOT_FOUND); + if (newBook.available_copies <= 0) { + throw new AppError("Selected target book variant is completely out of stock.", httpStatus.BAD_REQUEST); + } + await newBook.decrement("available_copies", { by: 1, transaction: t }); + await newBook.increment("lending_count", { by: 1, transaction: t }); + } + + // 📈 DYNAMIC EXTENSION STATE HANDLING const finalTargetDueDate = payload.dueDate ? new Date(payload.dueDate) : new Date(issue.due_date); - // Strip time from both objects for an accurate comparison check const today = new Date(); const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const targetDueDateMidnight = new Date(finalTargetDueDate.getFullYear(), finalTargetDueDate.getMonth(), finalTargetDueDate.getDate()); diff --git a/server/src/modules/plans/plans.service.ts b/server/src/modules/plans/plans.service.ts index c6c4f2c..7383656 100644 --- a/server/src/modules/plans/plans.service.ts +++ b/server/src/modules/plans/plans.service.ts @@ -1,7 +1,8 @@ import { PlansRepository } from "./plans.repository.js"; import { ICreatePlanDTO, IUpdatePlanDTO } from "./plans.types.js"; -import { Op } from "sequelize"; +import { Op, literal } from "sequelize"; import MembershipPlan from "../../database/models/MembershipPlan.js"; +import Member from "../../database/models/Member.js"; export class PlansService { private plansRepository: PlansRepository; @@ -22,13 +23,13 @@ export class PlansService { return await this.plansRepository.createPlan(data); } + async editPlan(data: IUpdatePlanDTO): Promise { const targetPlan = await this.plansRepository.findPlanById(data.membership_plan_id); if (!targetPlan) { throw new Error("Target strategy scheme payload reference record not found."); } - // Verify name uniqueness isn't hijacked by a separate profile model context const structuralConflict = await MembershipPlan.findOne({ where: { plan_name: data.plan_name, @@ -40,6 +41,12 @@ export class PlansService { throw new Error(`Strategic name conflict framework. Profile alias "${data.plan_name}" is assigned elsewhere.`); } + // 🌟 PROFESSIONAL CALCULATION LAYER: Calculate duration days delta differential + const oldDuration = Number(targetPlan.duration_days); + const newDuration = Number(data.duration_days); + const daysDifference = newDuration - oldDuration; + + // Update the core plan configuration settings await this.plansRepository.updatePlan(data.membership_plan_id, { plan_name: data.plan_name, price: data.price, @@ -47,6 +54,46 @@ export class PlansService { max_books_allowed: data.max_books_allowed }); + // 🌟 CASCADE DATABASE MIGRATION ENGINE + // If the timeline shifted, cascade update every single member attached to this blueprint + if (daysDifference !== 0) { + await Member.update( + { + // Dynamically adds or subtracts days across your SQL date records + expiry_date: literal(`expiry_date + INTERVAL '${daysDifference} days'`), + }, + { + where: { + membership_plan_id: data.membership_plan_id, + } + } + ); + + // Re-verify expiration boundaries for the modified records instantly + const todayString = new Date().toISOString().split('T')[0]; + await Member.update( + { membership_status: "EXPIRED" }, + { + where: { + membership_plan_id: data.membership_plan_id, + expiry_date: { [Op.lt]: todayString } + } + } + ); + + // Reverse check: Re-activate profiles if timeline extensions pushed them out of expiration + await Member.update( + { membership_status: "ACTIVE" }, + { + where: { + membership_plan_id: data.membership_plan_id, + expiry_date: { [Op.gte]: todayString }, + membership_status: "EXPIRED" + } + } + ); + } + const refreshedPlan = await this.plansRepository.findPlanById(data.membership_plan_id); return refreshedPlan!; } From 1cec7d124fdbc877a62b06654f14c2041f1070d6 Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 16 Jun 2026 11:36:58 +0530 Subject: [PATCH 03/11] fix: Imporve the librarian UI --- .../books/components/BookDetailModal.tsx | 225 +++-- .../features/books/components/BookModal.tsx | 250 +++--- .../books/components/DeleteBookModal.tsx | 104 ++- client/src/features/books/pages/BooksPage.tsx | 801 +++++++++--------- .../components/AddCategoryModal.tsx | 133 +++ .../components/CategoryDetailsModal.tsx | 289 ++++--- .../DeleteCategoryConfirmationModal.tsx | 114 +++ .../categories/pages/ManageCategories.tsx | 795 ++++++++--------- .../categories/types/category.types.ts | 1 + .../dashboard/components/AmnestySimulator.tsx | 14 +- .../dashboard/components/CategoryTreeMap.tsx | 20 +- .../components/CriticalDeficitWidget.tsx | 8 +- .../dashboard/components/DeadStockWidget.tsx | 20 +- .../components/EngagementLeaderboard.tsx | 18 +- .../components/FineVelocityGauge.tsx | 16 +- .../dashboard/components/MetricsGrid.tsx | 246 ++++-- .../dashboard/components/OverdueTable.tsx | 24 +- .../dashboard/components/PeakHoursChart.tsx | 18 +- .../components/RetentionAnalytics.tsx | 22 +- .../dashboard/components/ReturnForecaster.tsx | 18 +- .../fines/components/DeleteFinesModal.tsx | 122 +-- .../fines/components/FineDetailsModal.tsx | 196 ++--- .../components/FinesNotificationBanner.tsx | 4 +- .../fines/components/RestoreFineModal.tsx | 74 +- .../components/SettleFinePaymentModal.tsx | 179 ++-- client/src/features/fines/pages/FinesPage.tsx | 313 ++++--- .../components/DeleteTransactionModal.tsx | 146 ++-- .../issues/components/IssueDetailsModal.tsx | 281 +++--- .../issues/components/TransactionModal.tsx | 110 +-- .../issues/pages/TransactionsPage.tsx | 467 +++++----- .../components/DeleteConfirmationModal.tsx | 19 +- .../members/components/MemberDetailsModal.tsx | 291 +++---- .../members/components/MemberModal.tsx | 224 ++--- .../features/members/pages/MembersPage.tsx | 655 +++++++------- .../components/DeletePlanModal.tsx | 31 +- .../components/PlanDetailsModal.tsx | 201 +++-- .../membershipPlans/components/PlanModal.tsx | 238 +++--- .../membershipPlans/pages/ManagePlan.tsx | 572 ++++++------- .../components/ConfirmationModal.tsx | 110 +-- .../components/ReturnedDetailsModal.tsx | 94 +- .../returnedbooks/pages/ReturnedBooks.tsx | 354 ++++---- client/src/index.css | 4 +- client/src/layouts/DashboardLayout.tsx | 295 ++++--- client/src/pages/Dashboard.tsx | 84 +- client/src/types/members.ts | 1 + server/src/modules/books/book.controller.ts | 6 +- server/src/modules/books/book.repository.ts | 86 +- server/src/modules/books/book.service.ts | 8 +- server/src/modules/books/book.spec.ts | 742 ++++++++-------- server/src/modules/books/book.validation.ts | 11 +- .../categories/categories.repository.ts | 110 +-- .../modules/categories/categories.service.ts | 54 +- .../modules/dashboard/dashboard.repository.ts | 58 +- .../src/modules/members/member.controller.ts | 17 + .../src/modules/members/member.repository.ts | 181 +++- server/src/modules/members/member.routes.ts | 60 +- server/src/modules/members/member.service.ts | 20 + server/src/modules/members/member.types.ts | 23 + .../src/modules/members/member.validation.ts | 11 + 59 files changed, 5268 insertions(+), 4320 deletions(-) create mode 100644 client/src/features/categories/components/AddCategoryModal.tsx create mode 100644 client/src/features/categories/components/DeleteCategoryConfirmationModal.tsx diff --git a/client/src/features/books/components/BookDetailModal.tsx b/client/src/features/books/components/BookDetailModal.tsx index 9ecaa10..2a4636e 100644 --- a/client/src/features/books/components/BookDetailModal.tsx +++ b/client/src/features/books/components/BookDetailModal.tsx @@ -2,20 +2,6 @@ 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; @@ -51,138 +37,137 @@ export const BookDetailModal = ({ onDeleteTrigger(); // Execute the original deletion action from BooksPage.tsx }; - return ( - <> -
-
- {/* Header section */} -
-
-

- Book Details -

-

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

-
- +return ( + <> + {/* High-contrast background overlay with clean backdrop filter */} +
+
+ + {/* Header Framework - Matching Reference Module */} +
+
+

+ Book Details +

+

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

+ +
- {/* Detailed Metadata Grid */} -
-
-
- -
- {book.title} -
-
- -
- -
- {book.author} + {/* Detailed Metadata Body Context Frame */} +
+
+ + {/* Top Row Title Stack */} +
+
+

+ {book.title} +

+

+ by {book.author} +

-
+ 0 + ? "bg-emerald-50 text-emerald-700 border border-emerald-200" + : "bg-rose-50 text-rose-700 border border-rose-200" + }`} + > + 0 ? "bg-emerald-500" : "bg-rose-500"}`} /> + {book.availableCopies > 0 ? "Available" : "Out of Stock"} +
-
- -
- {book.language} -
-
+
-
+ {/* Core Info Properties Grid Layout */} +
- -
- {book.categoryName} -
-
-
- -
- {shelfEntryDate} -
+ + Language Spec + + + {book.language} +
-
-
- - Total Owned + + Category Classification - - {book.totalCopies} + + {book.categoryName}
+
- - On Shelf + + Shelf Registry Date - - {book.availableCopies} + + {shelfEntryDate}
+
- - Lending Count - - - {book.lendingCount}× + + Lending Metrics (Total / Available / Loans) +
+ + {book.totalCopies} Total + + + {book.availableCopies} On Shelf + + + {book.lendingCount}× Borrowed + +
-
- {/* Action Panel Footer */} -
- - + + +
+
+
- {/* Embedded inline confirmation screen */} - setIsDeleteOpen(false)} - onConfirm={handleConfirmDeletion} - bookTitle={book.title} - /> - - ); -}; + {/* 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 748a1cf..163d29d 100644 --- a/client/src/features/books/components/BookModal.tsx +++ b/client/src/features/books/components/BookModal.tsx @@ -7,7 +7,7 @@ import { axiosClient } from "../../../api/axiosClient"; import { toast } from "sonner"; // Editorial Visual Assets -import { Sparkles, BookOpen, User, Layers, Hash, X } from "lucide-react"; +import { Sparkles } from "lucide-react"; interface ScoredLine { originalText: string; @@ -39,6 +39,7 @@ export const BookModal = ({ return saved ? parseInt(saved, 10) : 0; }); + // Default values set to clean base states, letting the useEffect handle pre-filling on open const { register, handleSubmit, @@ -47,28 +48,37 @@ export const BookModal = ({ formState: { errors }, } = useForm({ resolver: zodResolver(BookFormSchema), - defaultValues: { title: "", author: "", language:"", totalCopies: 1, categoryId: "" }, + defaultValues: { + title: "", + author: "", + language: "", + totalCopies: 1, + categoryId: "", + }, }); - // Simple synchronization cycle without cascading state updates + // Safe Synchronization Cycle: Triggers updates explicitly when the target references alter useEffect(() => { - if (editingBook) { - reset({ - title: editingBook.title, - author: editingBook.author, - language: editingBook.language, - totalCopies: editingBook.totalCopies, - categoryId: editingBook.categoryId, - }); - } else { - reset({ title: "", author: "", language:"", totalCopies: 1, categoryId: "" }); + if (isOpen) { + if (editingBook) { + reset({ + title: editingBook.title, + author: editingBook.author, + language: editingBook.language || "", + totalCopies: editingBook.totalCopies, + categoryId: editingBook.categoryId, + }); + } else { + // Keeps the form clean when triggering "Add New Book" + reset({ title: "", author: "", language: "", totalCopies: 1, categoryId: "" }); + } } - }, [editingBook, reset]); + }, [editingBook, isOpen, reset]); // Safely cleans state artifacts within action contexts outside of reactive render loops const handleCloseModal = () => { setOcrAlternatives([]); - reset(); + reset({ title: "", author: "", language: "", totalCopies: 1, categoryId: "" }); onClose(); }; @@ -140,35 +150,43 @@ export const BookModal = ({ if (!isOpen) return null; return ( -
+
0 ? "w-full max-w-4xl" : "w-full max-w-lg"}`} + className={`bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-200 flex flex-col md:flex-row max-h-[90vh] transition-all duration-300 ${ocrAlternatives.length > 0 ? "w-full max-w-4xl" : "w-full max-w-xl"}`} > {/* LEFT COMPONENT: Primary Form Input Layout */}
- {/* Modal Branding Header - Clean Dark Structured Banner */} -
-

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

+ + {/* Header Framework - Matching Reference Module Layout */} +
+
+

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

+

+ {editingBook ? "Update Existing Record" : "Create New Inventory Registry"} +

+
-
+
+ + {/* Intelligent OCR Scanning Engine Header Frame */} {!editingBook && ( -
+
-
@@ -177,106 +195,106 @@ export const BookModal = ({ accept="image/*" onChange={handleAIScanUpload} disabled={isScanning || scanCounter >= 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" + 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-[#2B6CB0] file:text-white hover:file:bg-[#1A365D] transition-all cursor-pointer" />
)} -
-
- - - {errors.title && ( -

- {errors.title.message} -

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

- {errors.author.message} -

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

- {errors.author.message} -

- )} -
+ + + {/* Core Info Properties Fields */} +
+
+ + + {errors.title && ( +

+ {errors.title.message} +

+ )} +
-
-
+
-
- {/* Action Footer Frame */} -
+ {/* Operations Layout Action Buttons */} +
+
@@ -285,14 +303,12 @@ export const BookModal = ({ {/* RIGHT COMPONENT: Interactive Diagnostic Mapping Helper Column */} {!editingBook && ocrAlternatives.length > 0 && ( -
-

- OCR Layout - Fragments +
+

+ OCR Layout Fragments

-

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

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

@@ -300,29 +316,29 @@ export const BookModal = ({ return (
-
+
{item.translatedText}
{item.originalText !== item.translatedText && ( -
+
Raw: "{item.originalText}"
)} -
+
@@ -336,4 +352,4 @@ export const BookModal = ({
); -}; +}; \ No newline at end of file diff --git a/client/src/features/books/components/DeleteBookModal.tsx b/client/src/features/books/components/DeleteBookModal.tsx index 4d47119..7786515 100644 --- a/client/src/features/books/components/DeleteBookModal.tsx +++ b/client/src/features/books/components/DeleteBookModal.tsx @@ -17,47 +17,75 @@ export const DeleteBookModal = ({ if (!isOpen) return null; return ( -
-
-
-
- -
- -

- 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. -

+ {/* Header */} +
+

+ Delete Book From Catalog +

+ + +
+ + {/* Content */} +
+
+
- {/* Action Panel Buttons */} -
- - +

+ Confirm Book Deletion +

+ +

+ Are you sure you want to permanently remove + + {" "} + "{bookTitle}" + + {" "}from the library catalog? +

+ + {/* Warning Card */} +
+ + Permanent Action + + +

+ This operation cannot be undone. All associated book copies, + borrowing references, and catalog tracking history linked to this + title may become unavailable after deletion. +

+ + {/* Footer */} +
+ + + +
- ); -}; +
+);} \ No newline at end of file diff --git a/client/src/features/books/pages/BooksPage.tsx b/client/src/features/books/pages/BooksPage.tsx index 833cfcb..cf5ab28 100644 --- a/client/src/features/books/pages/BooksPage.tsx +++ b/client/src/features/books/pages/BooksPage.tsx @@ -1,172 +1,148 @@ -import { useState, useEffect } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { axiosClient } from "../../../api/axiosClient"; import { BookModal } from "../components/BookModal"; -import { DeleteBookModal } from "../components/DeleteBookModal"; import { BookDetailModal } from "../components/BookDetailModal"; import type { BookInventoryItem, BookCategory, LanguageCategory } from "../../../types/books"; import type { BookFormValues } from "../schemas/bookSchema"; import { toast } from "sonner"; - -// Editorial Icon Elements +import { useAuthStore } from "../../../store/authStore"; import { - Search, Plus, + Search, RotateCcw, - Award, - BookOpen, - ChevronLeft, - ChevronRight, - ChevronDown, X, + BookOpen, + ChevronDown } from "lucide-react"; +type RawLanguageResponse = string; + export const BooksPage = () => { const queryClient = useQueryClient(); - // Dynamic Search Orchestration states - const [localSearch, setLocalSearch] = useState(""); - const [activeSearchQuery, setActiveSearchQuery] = useState(""); + // Search filter parameters + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState(""); const [languageFilter, setLanguageFilter] = useState(""); - // Dynamic Pagination Tracking States - const [currentPage, setCurrentPage] = useState(1); - const RECORDS_PER_PAGE = 10; + // UI state tracking for active header filter menus + const [activeHeaderDropdown, setActiveHeaderDropdown] = useState<"category" | "language" | null>(null); - // Modal Visibility States + // Modal open states const [isFormOpen, setIsFormOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false); - const [selectedBook, setSelectedBook] = useState( - null, - ); + const [selectedBook, setSelectedBook] = useState(null); - // ========================================================================= - // ⏱️ NATIVE DEBOUNCE LIFECYCLE ENGINE - // ========================================================================= + const token = useAuthStore((state) => state.token); + + // Refs for tracking outside dropdown menu clicks + const categoryDropdownRef = useRef(null); + const languageDropdownRef = useRef(null); + + // Close filters if a user clicks outside the element boundary boxes useEffect(() => { - const debounceTimer = setTimeout(() => { - setActiveSearchQuery(localSearch); - setCurrentPage(1); // Snap back to Page 1 when filters change - }, 500); - - return () => clearTimeout(debounceTimer); - }, [localSearch]); - - // 1. Fetching relational query datasets - const { data: booksResponse, isLoading } = useQuery({ - queryKey: [ - "libraryBooksCatalogFeed", - activeSearchQuery, - categoryFilter, - languageFilter, - currentPage, - ], + const handleOutsideClick = (event: MouseEvent) => { + if ( + activeHeaderDropdown === "category" && + categoryDropdownRef.current && + !categoryDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + if ( + activeHeaderDropdown === "language" && + languageDropdownRef.current && + !languageDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [activeHeaderDropdown]); + + // Dynamic paginated data grid query cache feed layer + const { data: booksPayload, isLoading } = useQuery({ + queryKey: ["libraryBooksCatalogFeed", token, currentPage, searchTerm, categoryFilter, languageFilter], queryFn: async () => { - const response = await axiosClient.get("/books", { + const res = await axiosClient.get("/books", { params: { page: currentPage, - limit: RECORDS_PER_PAGE, - search: activeSearchQuery || undefined, + limit: 10, + search: searchTerm || undefined, category_id: categoryFilter || undefined, language: languageFilter || undefined, }, }); - return response.data; + + const rootData = res.data?.data || res.data; + const rawRecords = rootData?.rows || rootData?.data || []; + const totalCount = Number(rootData?.count || rootData?.meta?.total || 0); + + const globalTotalCopies = rootData?.meta?.globalTotalCopies ?? 0; + const globalAvailableCopies = rootData?.meta?.globalAvailableCopies ?? 0; + + const transformed = Array.isArray(rawRecords) + ? rawRecords.map((dbRow: unknown): BookInventoryItem => { + const row = dbRow as Record; + const catObj = (row.category || {}) as Record; + + return { + id: String(row.book_id || row.id || ""), + title: String(row.book_name || "Untitled Volume"), + author: String(row.book_author || "Unknown Author"), + totalCopies: Number(row.total_copies || row.totalCopies || 0), + availableCopies: Number(row.available_copies ?? row.availableCopies ?? 0), + language: String(row.language || "Not Mentioned"), + lendingCount: Number(row.lending_count || row.lendingCount || 0), + categoryId: String(row.category_id || row.categoryId || ""), + categoryName: String(catObj.name || row.categoryName || "Unclassified"), + createdAt: String(row.created_at || row.createdAt || new Date().toISOString()), + }; + }) + : []; + + return { + total: totalCount, + globalTotal: globalTotalCopies, + globalAvailable: globalAvailableCopies, + data: transformed + }; }, + enabled: !!token, }); - // Dedicated Category Dropdown Feed route + // Category selection options list feed route query const { data: categories = [] } = useQuery({ - queryKey: ["bookCategoriesDropdownFeed"], + queryKey: ["bookCategoriesDropdownFeed", token], queryFn: async () => { - const response = await axiosClient.get("/books/categories"); - return response.data?.data || []; + const res = await axiosClient.get("/books/categories"); + return res.data?.data || res.data || []; }, + enabled: !!token, }); -const { data: languages = [] } = useQuery({ - queryKey: ["bookLanguageDropdownFeed"], - queryFn: async (): Promise => { - const response = await axiosClient.get("/books/languages"); - return response.data?.data || []; - }, - select: (rawData: string[]): LanguageCategory[] => { - if (!Array.isArray(rawData)) return []; - - return rawData.map((langName: string) => ({ - id: langName, - name: langName, - })); - } -}); - - // Structural mapping layer for explicit type safety checks - interface BackendBookRow { - book_id?: string; - id?: string; - book_name?: string; - book_author?: string; - total_copies?: number; - totalCopies?: number; - available_copies?: number; - availableCopies?: number; - lending_count?: number; - lendingCount?: number; - category_id?: string; - categoryId?: string; - created_at?: string; - createdAt?: string; - category?: { - name: string; - }; - categoryName?: string; - language:string; - } - - // Extract core rows and pagination totals safely - const rawRows: BackendBookRow[] = booksResponse?.data?.rows || []; - const totalDatabaseRecords = Number(booksResponse?.data?.count || 0); - const totalPages = Math.max( - 1, - Math.ceil(totalDatabaseRecords / RECORDS_PER_PAGE), - ); - - // 2. Data Adaptation Mapping - const parsedBooks: BookInventoryItem[] = rawRows.map((b: BackendBookRow) => ({ - id: String(b.book_id || b.id || ""), - title: String(b.book_name || "Untitled volume"), - author: String(b.book_author || "Unknown"), - totalCopies: Number(b.total_copies || b.totalCopies || 0), - availableCopies: Number(b.available_copies ?? b.availableCopies ?? 0), - language: String(b.language || "Not Mentioned"), - lendingCount: Number(b.lending_count || b.lendingCount || 0), - categoryId: String(b.category_id || b.categoryId || ""), - categoryName: String(b.category?.name || b.categoryName || "Unclassified"), - createdAt: String(b.created_at || b.createdAt || new Date().toISOString()), - })); - - // 3. Operational Filter Reset Handler - const handleClearAllFilters = () => { - setLocalSearch(""); - setActiveSearchQuery(""); - setCategoryFilter(""); - setLanguageFilter(""); - setCurrentPage(1); - }; - - const handleCategoryChange = (val: string) => { - setCategoryFilter(val); - setCurrentPage(1); - }; - - const handleLanguageChange = (val: string) => { - setLanguageFilter(val); - setCurrentPage(1); - }; + // FIXED: Explicitly corrected mapping types to parse languages correctly + const { data: languages = [] } = useQuery({ + queryKey: ["bookLanguageDropdownFeed", token], + queryFn: async (): Promise => { + const res = await axiosClient.get("/books/languages"); + const rootData = res.data?.data || []; + return Array.isArray(rootData) ? rootData : []; + }, + select: (rawData: RawLanguageResponse[]): LanguageCategory[] => { + return rawData.map((langName: string) => ({ + id: langName, + name: langName, + })); + }, + enabled: !!token, + }); - // Save/Update Book Mutation Pipeline + // Save/Update mutation execution framework pipelines const saveBookMutation = useMutation({ mutationFn: async (payload: BookFormValues) => { const processedPayload = { @@ -178,315 +154,382 @@ const { data: languages = [] } = useQuery({ }; if (selectedBook) { - const response = await axiosClient.patch( - `/books/${selectedBook.id}`, - processedPayload, - ); - return response.data; + return await axiosClient.patch(`/books/${selectedBook.id}`, processedPayload); } - - const response = await axiosClient.post("/books", processedPayload); - return response.data; + return await axiosClient.post("/books", processedPayload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["libraryBooksCatalogFeed"] }); - toast.success( - selectedBook - ? "Book metrics updated successfully." - : "New title appended to index safely!", - ); + queryClient.invalidateQueries({ queryKey: ["bookLanguageDropdownFeed"] }); + toast.success(selectedBook ? "Book metrics updated successfully." : "New title appended to index safely!"); setIsFormOpen(false); setSelectedBook(null); }, - onError: () => { - toast.error( - "Database schema transaction rejected our data structure formats.", - ); + onError: (error: unknown) => { + let msg = "Database validation failure."; + if (error instanceof AxiosError) msg = error.response?.data?.message || msg; + toast.error(msg); }, }); - // Delete Book Mutation Pipeline + // Delete inventory item target execution pipeline const deleteBookMutation = useMutation({ - mutationFn: async (id: string) => { - const response = await axiosClient.delete(`/books/${id}`); - return response.data; + mutationFn: async (bookId: string) => { + return await axiosClient.delete(`/books/${bookId}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["libraryBooksCatalogFeed"] }); + queryClient.invalidateQueries({ queryKey: ["bookLanguageDropdownFeed"] }); toast.success("Volume profile purged from repository catalog."); - setIsDeleteOpen(false); setSelectedBook(null); - - if (parsedBooks.length === 1 && currentPage > 1) { - setCurrentPage((prev) => prev - 1); - } }, onError: () => { toast.error("Unable to execute target ledger deletion contract."); }, }); - + const bookList = booksPayload?.data || []; + const hasActiveFilters = Boolean(searchTerm || categoryFilter || languageFilter); + const displayTotal = booksPayload?.total ?? 0; + + const displayGlobalTotal = booksPayload?.globalTotal ?? 0; + const displayGlobalAvailable = booksPayload?.globalAvailable ?? 0; + + const totalPages = Math.ceil(displayTotal / 10) || 1; + + const handleClearFilters = () => { + setSearchTerm(""); + setCategoryFilter(""); + setLanguageFilter(""); + setCurrentPage(1); + }; return ( -
- {/* 💳 Top Deck Banner (Synchronized heights, margins, and layout) */} -
+
+ + {/* HEADER BLOCK WITH CORRESPONDING RESPONSIVE LIVE METRICS STRIPS */} +
-

+
+ Inventory +
+

Books Management Desk -

-

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

-
- -
- - {/* 🎛️ Control Filtering Deck (Inline grid alignment setup) */} -
- {/* Search Input Box Container */} -
- - - - setLocalSearch(e.target.value)} - className="w-full pl-10 pr-10 py-2 bg-slate-50 border border-border-main text-text-main rounded-xl text-xs sm:text-sm font-medium outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" - /> - {localSearch && ( - - )} +

- {/* Filters Grouping Grid */} -
- {/* Category Dropdown Selection Box Container */} -
- - - + {/* Dynamic Metric Tracker Cards Block */} +
+
+ + {displayTotal} + + + {hasActiveFilters ? "Matched Volumes" : "Total Titles"}
- - - - {/* Language Dropdown Selection Box Container */} -
- - - +
+
+ + {displayGlobalAvailable} + + + On-Shelf Available
+
+
+ + {displayGlobalTotal} + + + Total Managed Copies + +
+
+
+ +
+ + {/* CORE CONTROL TOOLBAR UTILITY BAR LINE */} +
+
+ Volumes Ledger +
+ +
+
+ + { setSearchTerm(e.target.value); setCurrentPage(1); }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> + {searchTerm && ( + + )} +
- {/* Reset Action Control Trigger */} + +
+ +
- {/* 📊 Main Table Grid Render Block Layout */} - {isLoading ? ( -
- Syncing Active Media Ledger Records... -
- ) : ( -
-
-
- - - - - - - - - - - - - {parsedBooks.length === 0 ? ( - - - - ) : ( - parsedBooks.map((book) => ( - { - setSelectedBook(book); - setIsDetailOpen(true); - }} - className="hover:bg-slate-50/80 transition-colors cursor-pointer select-none group" - > - { + setSelectedBook(book); + setIsDetailOpen(true); + }} + className={`transition-all duration-150 cursor-pointer border-l-4 ${ + isCurrentSelection + ? 'bg-slate-50/80 border-l-4 border-l-blue-500' + : 'hover:bg-blue-50/40 border-l-4 border-l-transparent' + }`} + > + + + + + + + + + ); + }) + )} + +
Book Title & Creator IndexLanguageCategoryTotal Volumes - Shelf Availability - - Book Lending Count -
- Operational Clear View. Zero matching volume records - found inside database indexes. -
-
- {book.title} -
-
- By {book.author} + {/* PRIMARY DATA VIEWS LAYOUT CONTAINER */} +
+
+ {isLoading ? ( +
+ Syncing active media ledger sequences... +
+ ) : ( +
+
+ + + + + + {/* LANGUAGE INTERACTIVE SELECT HEADER CELL */} + - - - + + + + {bookList.length === 0 ? ( + + - )) - )} - -
Book Title & Creator Index + + + {activeHeaderDropdown === "language" && ( +
e.stopPropagation()} + className="absolute left-4 top-7 z-50 w-40 bg-white border border-gray-200 rounded-lg shadow-xl py-1.5 text-xs text-[#2D3748] font-medium normal-case tracking-normal max-h-60 overflow-y-auto" + > + + {languages.map((lang) => ( + + ))}
- -
- - {book.language} - - - - - {book.categoryName} - - - {book.totalCopies} - - 0 - ? "bg-emerald-50 text-emerald-700 border-emerald-100" - : "bg-rose-50 text-rose-700 border-rose-100" - }`} + )} + + + {/* CATEGORY INTERACTIVE SELECT HEADER CELL */} + + + + {activeHeaderDropdown === "category" && ( +
e.stopPropagation()} + className="absolute left-4 top-7 z-50 w-48 bg-white border border-gray-200 rounded-lg shadow-xl py-1.5 text-xs text-[#2D3748] font-medium normal-case tracking-normal max-h-60 overflow-y-auto" > - {book.availableCopies} left - - -
-
- - {book.lendingCount} times + + {categories.map((cat) => ( + + ))}
+ )} + + +
Availability
+ No matching records currently indexed inside this filtered view.
-
+ ) : ( + bookList.map((book) => { + const isCurrentSelection = selectedBook?.id === book.id && isDetailOpen; + return ( +
+
+
+ {book.title} +
+
+ By {book.author} +
+
+
+ + {book.language} + + +
+ {book.categoryName} +
+
+ + 0 ? "bg-emerald-500" : "bg-rose-500"}`} /> + 0 ? "text-emerald-700 font-mono" : "text-rose-700 font-mono"}> + {book.availableCopies} / {book.totalCopies} Left + + +
+
- {/* 🏁 Standard Footer Pagination Row Controls Container */} - {totalPages > 0 && ( -
- - Page {currentPage} / {totalPages}{" "} - | Total{" "} - {totalDatabaseRecords} Books - -
- - + {/* PAGINATION SUITE LAYER BLOCK CONTROLS */} + {totalPages > 0 && ( +
+ Page {currentPage} of {totalPages} +
+ + +
-
- )} -
+ )} +
+ )}
- )} +
- {/* 🪟 Modals Infrastructure Overlay Layer Blocks */} + {/* THE INTEGRATED BOOK DETAIL DIALOG POPUP MODAL */} setIsDetailOpen(false)} book={selectedBook} - onEditTrigger={() => setIsFormOpen(true)} - onDeleteTrigger={() => setIsDeleteOpen(true)} + onClose={() => { + setIsDetailOpen(false); + setSelectedBook(null); // Keep this safely here ONLY for standard manual modal exits + }} + onEditTrigger={() => { + // CRITICAL FIX: Close the detail overlay window, but DO NOT nullify selectedBook! + setIsDetailOpen(false); + setIsFormOpen(true); + }} + onDeleteTrigger={() => { + if (selectedBook) { + deleteBookMutation.mutate(selectedBook.id); + setIsDetailOpen(false); + } + }} /> + {/* OVERLAY CONFIGURATION/ADD FORM DIALOG LAYER */} setIsFormOpen(false)} + onClose={() => { + setIsFormOpen(false); + setSelectedBook(null); // Cleans up after form submission/cancellation + }} onSubmit={(vals) => saveBookMutation.mutate(vals)} categories={categories} editingBook={selectedBook} /> - - setIsDeleteOpen(false)} - onConfirm={() => - selectedBook && deleteBookMutation.mutate(selectedBook.id) - } - bookTitle={selectedBook?.title || ""} - />
); -}; +}; \ No newline at end of file diff --git a/client/src/features/categories/components/AddCategoryModal.tsx b/client/src/features/categories/components/AddCategoryModal.tsx new file mode 100644 index 0000000..a37e282 --- /dev/null +++ b/client/src/features/categories/components/AddCategoryModal.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import type { CategoryMetrics } from "../types/category.types"; + +interface AddCategoryModalProps { + isOpen: boolean; + onClose: () => void; + existingCategories: CategoryMetrics[]; + onCreateCategory: (categoryName: string) => Promise; + isMutating: boolean; +} + +export const AddCategoryModal: React.FC = ({ + isOpen, + onClose, + existingCategories, + onCreateCategory, + isMutating, +}) => { + const [categoryName, setCategoryName] = useState(""); + const [localError, setLocalError] = useState(""); + + // Clean exit wrapper to clear parameters when closed + const handleClose = () => { + setCategoryName(""); + setLocalError(""); + onClose(); + }; + + if (!isOpen) return null; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmedName = categoryName.trim(); + + if (!trimmedName) { + setLocalError("Category name cannot be left blank"); + return; + } + + if (!/^[A-Za-z\s]+$/.test(trimmedName)) { + setLocalError("Name must contain alphabets only"); + return; + } + + const nameExists = existingCategories.some( + (cat) => cat.category_name.toLowerCase() === trimmedName.toLowerCase() + ); + + if (nameExists) { + setLocalError(`The category "${trimmedName}" already exists`); + return; + } + + setLocalError(""); + await onCreateCategory(trimmedName); + + // Clear state inputs on successful creation + setCategoryName(""); + onClose(); + }; + + return ( +
+ {/* Added overflow-hidden below to clip the square, gray background footer into the parent's rounded track */} +
+ + {/* Header Block */} +
+
+

+ Create New Category +

+

+ System Registry Setup +

+
+ +
+ + {/* Input Field Form */} +
+
+
+ + { + setCategoryName(e.target.value); + if (localError) setLocalError(""); + }} + className="w-full px-3 py-2.5 bg-slate-50 border border-gray-200 text-sm font-semibold text-[#2D3748] rounded-xl outline-hidden focus:bg-white focus:border-[#1A365D] focus:ring-4 focus:ring-slate-900/5 transition-all" + /> + {localError && ( +

+ ⚠️ {localError} +

+ )} +
+
+ + {/* Footer Action Buttons */} +
+ + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/features/categories/components/CategoryDetailsModal.tsx b/client/src/features/categories/components/CategoryDetailsModal.tsx index 2dd7e4f..281620e 100644 --- a/client/src/features/categories/components/CategoryDetailsModal.tsx +++ b/client/src/features/categories/components/CategoryDetailsModal.tsx @@ -1,16 +1,6 @@ import React, { useState } from "react"; import type { CategoryMetrics } from "../types/category.types"; - -// Editorial Visual Assets -import { - Layers, - BookOpen, - BarChart3, - Calendar, - Edit3, - Trash2, - X, -} from "lucide-react"; +import { DeleteCategoryConfirmationModal } from "./DeleteCategoryConfirmationModal"; // Adjust path if needed interface CategoryDetailsModalProps { isOpen: boolean; @@ -32,15 +22,19 @@ export const CategoryDetailsModal: React.FC = ({ const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(""); const [localError, setLocalError] = useState(""); - const [prevIsOpen, setPrevIsOpen] = useState(false); + + // Track confirmation dialog state parameters locally + const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false); + // Synchronize state parameters when modal opens if (isOpen !== prevIsOpen) { setPrevIsOpen(isOpen); if (isOpen && category) { setEditName(category.category_name); setLocalError(""); setIsEditing(false); + setIsConfirmDeleteOpen(false); // Make sure confirmation layer resets } } @@ -63,135 +57,166 @@ export const CategoryDetailsModal: React.FC = ({ setIsEditing(false); }; + const handleConfirmDeleteTransaction = () => { + onDeleteCategory(category); + setIsConfirmDeleteOpen(false); + }; + + const registryDate = new Date(category.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + return ( -
-
- {/* Header section - Clean Dark Structured Banner */} -
-
-

- Category Details -

-

- ID: {readableId} -

+ <> + {/* High-contrast background overlay with clean backdrop filter */} +
+
+ + {/* Header Framework */} +
+
+

+ Category Details +

+

+ ID: {readableId} +

+
+
- -
- {/* Detailed Metadata Layout */} -
-
- - {isEditing ? ( -
- setEditName(e.target.value)} - className="w-full px-4 py-2.5 bg-slate-50 border border-border-main text-sm font-semibold text-text-main rounded-xl placeholder:text-slate-400 outline-hidden focus:bg-card-bg focus:border-slate-900 focus:ring-4 focus:ring-slate-900/5 transition-all" - /> - {localError && ( -

- {localError} -

- )} -
- ) : ( -
- {category.category_name} + {/* Detailed Metadata Body Context Frame */} +
+
+ + {/* Top Row Title/Edit Stack */} +
+
+ {isEditing ? ( +
+ setEditName(e.target.value)} + className="w-full px-3 py-2 bg-slate-50 border border-gray-200 text-sm font-semibold text-[#2D3748] rounded-xl outline-hidden focus:bg-white focus:border-[#1A365D] focus:ring-4 focus:ring-slate-900/5 transition-all" + /> + {localError && ( +

+ {localError} +

+ )} +
+ ) : ( +
+

+ {category.category_name} +

+

+ Library Asset Classification +

+
+ )} +
- )} -
- {/* Stats Row layout */} -
-
- - Total Owned Books - - - {category.booksCount} - -
+
+ + {/* Core Info Properties Grid Layout */} +
+
+ + System Registry Date + + + {registryDate} + +
+ +
+ + Inventory Performance Metrics + +
+ + {category.booksCount || 0} Unique Titles + + + {category.totalCopies || 0} Total Copies + + + {category.lendingCount || 0}× Borrowed + +
+
+
-
- - Total Borrows - - - {category.lendingCount} - -
-
+ {/* Operations Layout Action Buttons */} +
+ {isEditing ? ( +
+ + +
+ ) : ( + <> + + + + + )} +
- {/* Registry Date layout */} -
- -
- {new Date(category.created_at).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - })}
- - {/* Action Panel Footer */} -
- - - {isEditing ? ( - <> - - - - ) : ( - - )} -
-
+ + {/* Layered Confirmation Prompt (Sets an explicit stacking indexing order profile z-60) */} + setIsConfirmDeleteOpen(false)} + onConfirm={handleConfirmDeleteTransaction} + category={category} + isMutating={isMutating} + /> + ); -}; +}; \ No newline at end of file diff --git a/client/src/features/categories/components/DeleteCategoryConfirmationModal.tsx b/client/src/features/categories/components/DeleteCategoryConfirmationModal.tsx new file mode 100644 index 0000000..76798e8 --- /dev/null +++ b/client/src/features/categories/components/DeleteCategoryConfirmationModal.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import type { CategoryMetrics } from "../types/category.types"; +import { Trash2 } from "lucide-react"; + +interface DeleteCategoryConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + category: CategoryMetrics | null; + isMutating: boolean; +} + +export const DeleteCategoryConfirmationModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + category, + isMutating, +}) => { + if (!isOpen || !category) return null; + + const hasBooks = (category.booksCount || 0) > 0; + + return ( +
+
+ + {/* Header Section */} +
+

+ Delete Category From Catalog +

+ + +
+ + {/* Content Section */} +
+ {/* Centered Trash Icon Frame with Adaptive Border Theme */} +
+ +
+ +

+ Confirm Category Deletion +

+ +

+ Are you sure you want to permanently remove the category{" "} + + "{category.category_name}" + {" "} + from the system records? +

+ + {/* Warning Card Condition Layers matching reference layout styling */} + {hasBooks ? ( +
+ + ⚠️ System Records Notice + + +

+ There are currently {category.booksCount} unique titles registered under this category. Dropping this registry will cause all associated books to be shown as unclassified inside the catalogs. +

+
+ ) : ( +
+ + ✓ Safe Drop Analysis + + +

+ No active book items are bound to this category. This classification can be dropped immediately without altering auxiliary indexing records. +

+
+ )} +
+ + {/* Footer Action Layout Tray */} +
+ + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/client/src/features/categories/pages/ManageCategories.tsx b/client/src/features/categories/pages/ManageCategories.tsx index caa38aa..07c6bef 100644 --- a/client/src/features/categories/pages/ManageCategories.tsx +++ b/client/src/features/categories/pages/ManageCategories.tsx @@ -1,513 +1,440 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { axiosClient } from "../../../api/axiosClient"; +import { CategoryDetailsModal } from "../components/CategoryDetailsModal"; import { toast } from "sonner"; import { useAuthStore } from "../../../store/authStore"; -import { CategoryDetailsModal } from "../components/CategoryDetailsModal"; -import ConfirmationModal from "../../returnedbooks/components/ConfirmationModal"; -import { categoryFormSchema } from "../validation/category.validation"; import type { CategoryMetrics } from "../types/category.types"; - -// Editorial Visual Assets +import { AddCategoryModal } from "../components/AddCategoryModal"; import { Plus, Search, - X, - ChevronDown, RotateCcw, - Layers, - BookOpen, - RefreshCw, - AlertTriangle, - FolderPlus, - ChevronLeft, - ChevronRight, + X, + Layers } from "lucide-react"; -interface UpdateMutationResponse { - data?: { - category_name?: string; - }; -} - -interface ServerPaginationWrapper { - rows: CategoryMetrics[]; - totalCount: number; - totalPages: number; - currentPage: number; +// Local structural shape handling the layout requirements of the table columns +interface DisplayCategory { + category_id: string; + category_name: string; + code: string; + description: string; + booksCount: number; + lendingCount: number; + isParent: boolean; + totalCopies: number; + parentName: string; + isActive: boolean; + created_at: string; + updated_at: string; } export const ManageCategories = () => { const queryClient = useQueryClient(); - const token = useAuthStore((state) => state.token); - // 🔎 Layout Control Parameters - const [searchQuery, setSearchQuery] = useState(""); - const [bookSort, setBookSort] = useState< - "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH" - >("NONE"); - const [borrowSort, setBorrowSort] = useState< - "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH" - >("NONE"); + // Search filter parameters const [currentPage, setCurrentPage] = useState(1); - const rowsPerPage = 10; - - // 📝 Creation Dialog Variables - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [newCatName, setNewCatName] = useState(""); - const [createValidationError, setCreateValidationError] = useState(""); - - // 📊 Detail Display Management State - const [selectedCategory, setSelectedCategory] = - useState(null); - const [isDetailsOpen, setIsDetailsOpen] = useState(false); - - // 🚨 Deletion Reconfirmation Setup State - const [deleteConfirmConfig, setDeleteConfirmConfig] = useState<{ - isOpen: boolean; - targetCat: CategoryMetrics | null; - }>({ - isOpen: false, - targetCat: null, - }); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); // "ACTIVE" | "INACTIVE" + const [typeFilter, setTypeFilter] = useState(""); // "PARENT" | "SUB" - // Query: Fetch structured aggregation framework from server - const { data: serverPayload, isLoading } = useQuery({ - queryKey: [ - "libraryCategoriesAggregationFeed", - token, - currentPage, - searchQuery, - bookSort, - borrowSort, - ], - queryFn: async () => { - const params = new URLSearchParams({ - page: currentPage.toString(), - limit: rowsPerPage.toString(), - bookSort, - borrowSort, - ...(searchQuery.trim() && { search: searchQuery.trim() }), + // Clean UI state parameters + const [activeHeaderDropdown, setActiveHeaderDropdown] = useState<"type" | "status" | null>(null); + + // Modal control triggers + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedCategory, setSelectedCategory] = useState(null); + + const token = useAuthStore((state) => state.token); + + // Refs for tracking outside dropdown clicks + const typeDropdownRef = useRef(null); + const statusDropdownRef = useRef(null); + + const [isAddOpen, setIsAddOpen] = useState(false); + + const createCategoryMutation = useMutation({ + mutationFn: async (newName: string) => { + // Change 'name' to 'category_name' to pass backend Zod validation + const response = await axiosClient.post("/categories", { + category_name: newName, }); - const res = await axiosClient.get( - `/categories/metrics?${params.toString()}`, - ); - return res.data?.data || res.data; + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categoriesListFeed"] }); + toast.success("Category registered successfully!"); + }, + onError: (error: unknown) => { + let errorMsg = "Failed to register category."; + if (error instanceof AxiosError) { + errorMsg = error.response?.data?.message || errorMsg; + } + toast.error(errorMsg); + console.error("Category creation error:", error); }, - enabled: !!token, }); - const categoriesList = serverPayload?.rows || []; - const totalRecordsCount = serverPayload?.totalCount || 0; - const totalPages = serverPayload?.totalPages || 1; + const handleCreateCategory = async (newName: string) => { + try { + await createCategoryMutation.mutateAsync(newName); + } catch { + // Intentionally empty: error handled globally inside mutation onError config + } + }; - // Mutation: Store New Row Asset - const createMutation = useMutation({ - mutationFn: async (name: string) => { - return await axiosClient.post("/categories", { category_name: name }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["libraryCategoriesAggregationFeed"], + // Close interactive headers if clicking outside + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + activeHeaderDropdown === "type" && + typeDropdownRef.current && + !typeDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + if ( + activeHeaderDropdown === "status" && + statusDropdownRef.current && + !statusDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [activeHeaderDropdown]); + + // Core background querying pipeline matching data schema rows + const { data: categoriesPayload, isLoading } = useQuery<{ + total: number; + globalActive: number; + globalInactive: number; + data: DisplayCategory[]; + }>({ + queryKey: ["categoriesListFeed", token, currentPage, searchTerm, typeFilter, statusFilter], + queryFn: async () => { + const res = await axiosClient.get("/categories/metrics", { + params: { + page: currentPage, + limit: 10, + search: searchTerm || undefined, + type: typeFilter || undefined, + status: statusFilter || undefined, + }, }); - toast.success("New category added successfully."); - setIsCreateOpen(false); - setNewCatName(""); + + const rootData = res.data?.data || res.data; + const rawRecords = rootData?.rows || []; + const totalCount = rootData?.totalCount || 0; + + const globalActiveCount = rootData?.globalActive ?? "-"; + const globalInactiveCount = rootData?.globalInactive ?? "-"; + + const transformed = Array.isArray(rawRecords) + ? rawRecords.map((dbRow: unknown): DisplayCategory => { + const row = dbRow as Record; + const parentObj = (row.parent_category || {}) as Record; + + return { + category_id: String(row.category_id || row.id || ""), + category_name: String(row.name || row.category_name || "Unnamed Category"), + code: String(row.code || row.slug || "—"), + description: String(row.description || "No description provided."), + booksCount: Number(row.booksCount || row.book_count || 0), + totalCopies: Number(row.totalCopies || row.total_copies || 0), + lendingCount: Number(row.lendingCount || row.lending_count || 0), + isParent: !row.parent_id, + parentName: String(parentObj.name || "—"), + isActive: row.status === "ACTIVE" || row.is_active === true, + created_at: String(row.created_at || row.createdAt || new Date().toISOString()), + updated_at: String(row.updated_at || row.updatedAt || new Date().toISOString()), + }; + }) + : []; + + return { + total: totalCount, + globalActive: globalActiveCount, + globalInactive: globalInactiveCount, + data: transformed + }; }, - onError: () => - toast.error("Server baseline engine rejected insertion pipeline."), + enabled: !!token, }); - // Mutation: Save Partial Update Edits - const updateMutation = useMutation({ - mutationFn: async ({ id, name }: { id: string; name: string }) => { - return await axiosClient.patch(`/categories/${id}`, { - category_name: name, - }); + // Mutation for updating category name inside the dynamic slider modal view + // Mutation for updating category name inside the dynamic slider modal view + const updateNameMutation = useMutation({ + mutationFn: async ({ id, newName }: { id: string; newName: string }) => { + // Changed 'name' to 'category_name' to pass backend Zod validation + return await axiosClient.patch(`/categories/${id}`, { category_name: newName }); }, - onSuccess: (res) => { - const responseData = res.data as UpdateMutationResponse | undefined; - queryClient.invalidateQueries({ - queryKey: ["libraryCategoriesAggregationFeed"], - }); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["categoriesListFeed"] }); toast.success("Category name updated successfully."); - - if (selectedCategory) { - setSelectedCategory((prev) => - prev - ? { - ...prev, - category_name: - responseData?.data?.category_name || prev.category_name, - } - : null, - ); - } + setIsModalOpen(false); + setSelectedCategory(null); + }, + onError: (error: unknown) => { + let msg = "Failed to rewrite classification name."; + if (error instanceof AxiosError) msg = error.response?.data?.message || msg; + toast.error(msg); }, - onError: () => - toast.error("Failed to alter category reference values safely."), }); - // Mutation: Permanent Row Cascade Purging - const deleteMutation = useMutation({ + // Mutation for handling category records purge transactions + const deleteCategoryMutation = useMutation({ mutationFn: async (id: string) => { return await axiosClient.delete(`/categories/${id}`); }, onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["libraryCategoriesAggregationFeed"], - }); - toast.success( - "Categories deleted successfully and Books under the category also removed.", - ); - setIsDetailsOpen(false); + queryClient.invalidateQueries({ queryKey: ["categoriesListFeed"] }); + toast.success("Category record successfully dropped from ledger."); + setIsModalOpen(false); setSelectedCategory(null); - setDeleteConfirmConfig({ isOpen: false, targetCat: null }); }, - onError: () => - toast.error("Database constraint rejected Delete transaction profile."), + onError: (error: unknown) => { + let msg = "Failed to drop targeted category registry stack."; + if (error instanceof AxiosError) msg = error.response?.data?.message || msg; + toast.error(msg); + }, }); - const handleOpenCreateModal = () => { - setNewCatName(""); - setCreateValidationError(""); - setIsCreateOpen(true); + const handleUpdateName = async (id: string, newName: string): Promise => { + await updateNameMutation.mutateAsync({ id, newName }); }; - const handleResetFilters = () => { - setSearchQuery(""); - setBookSort("NONE"); - setBorrowSort("NONE"); - setCurrentPage(1); + const handleDeleteCategory = (category: CategoryMetrics) => { + deleteCategoryMutation.mutate(category.category_id); }; - const handleExecuteCreate = async (e: React.FormEvent) => { - e.preventDefault(); - const activeNames = categoriesList.map((c) => c.category_name); - const validator = categoryFormSchema(activeNames).safeParse({ - categoryName: newCatName, - }); + const categoryList = categoriesPayload?.data || []; + const hasActiveFilters = Boolean(searchTerm || typeFilter || statusFilter); + const displayTotal = categoriesPayload?.total ?? 0; - if (!validator.success) { - setCreateValidationError(validator.error.issues[0].message); - return; - } + const totalPages = Math.ceil(categoryList.length / 10) || 1; - setCreateValidationError(""); - createMutation.mutate(newCatName.trim()); - }; - - const handleTriggerDeleteSequence = (cat: CategoryMetrics) => { - setDeleteConfirmConfig({ isOpen: true, targetCat: cat }); + const handleClearFilters = () => { + setSearchTerm(""); + setTypeFilter(""); + setStatusFilter(""); + setCurrentPage(1); }; - const isGlobalProcessing = - createMutation.isPending || - updateMutation.isPending || - deleteMutation.isPending; + const getInitials = (name: string) => (name ? name.charAt(0).toUpperCase() : "C"); return ( -
- {/* 💳 Top Deck Banner (Synchronized heights, gaps, paddings) */} -
+
+ + {/* ==================== ZONES A & B: ALIGNED HEADER WITH METRIC STRIP ==================== */} +
-

- Categories Management Desk -

-

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

-
- -
- - {/* 🎛️ Control Filtering Deck (Synchronized matching padding) */} -
- {/* Search Input Box Container */} -
- - - - ) => { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="w-full pl-10 pr-10 py-2 bg-slate-50 border border-border-main text-text-main rounded-xl text-xs sm:text-sm font-medium outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" - /> - {searchQuery && ( - - )} +
+ Catalog Management +
+

+ Book Categories +

- {/* Filters Grouping Grid (Aligned horizontally to the right on desktop layouts) */} -
- {/* Total Volume Filter dropdown wrapper */} -
- - - + {/* Metric Tracker Stack */} +
+
+ + {displayTotal} + + + {hasActiveFilters ? "Matched" : "Total Categories"}
+
+
- {/* Active Borrows Filter dropdown wrapper */} -
- - - - +
+ + {/* ==================== ZONE C: MINIMALIST UTILITIES SUB HEADER ==================== */} +
+
+ Classification Ledger +
+ + {/* Compact Right-Aligned Control Blocks */} +
+ + {/* Always-On Static Rounded Search Input Field Frame */} +
+ + { setSearchTerm(e.target.value); setCurrentPage(1); }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> + {searchTerm && ( + + )}
- {/* Reset Action Control Trigger */} + {/* Always-On Persistent Filters Clear Action Icon Trigger */} + +
+ + {/* Streamlined Action Core Button */} + {/* Update your Plus Button in ManageCategories.tsx to look exactly like this: */} +
- {/* 📊 Main Data Rendering Workspace */} - {isLoading ? ( -
- - Compiling catalog taxonomy architecture indices... -
- ) : ( -
-
-
- - - - - - - - - - {categoriesList.length === 0 ? ( - - + {/* ==================== ZONE D: STATIC FULL-WIDTH TABLE DISPLAY ==================== */} +
+
+ {isLoading ? ( +
+ Syncing active structural taxonomy layers... +
+ ) : ( +
+
+
- - Category Name Listing - - - Total Volume Stack - - Active Circulation Borrow Count -
- No library category profiles match the search key terms. -
+ + + + + - ) : ( - categoriesList.map((cat: CategoryMetrics) => ( - { - setSelectedCategory(cat); - setIsDetailsOpen(true); - }} - className="hover:bg-slate-50/80 transition-colors cursor-pointer group select-none" - > - - - + + {categoryList.length === 0 ? ( + + - )) - )} - -
Category NameTotal BooksTotal Copies
- {cat.category_name} - - {cat.booksCount} books - - - {cat.lendingCount} times - +
+ No matching category keys found inside this targeted segment.
-
- - {/* 🏁 Standard Synchronized Footer Pagination Engine Controls */} - {totalPages > 0 && ( -
- - Page {currentPage} / {totalPages}{" "} - | Total{" "} - {totalRecordsCount} Categories - -
- - -
+ ) : ( + categoryList.map((category) => { + return ( + { + setSelectedCategory({ + category_id: category.category_id, + category_name: category.category_name, + created_at: category.created_at, + booksCount: category.booksCount, + totalCopies: category.totalCopies, + lendingCount: category.lendingCount + } as CategoryMetrics); + setIsModalOpen(true); + }} + className="transition-all duration-150 cursor-pointer border-l-4 hover:bg-blue-50/40 border-l-transparent" + > + {/* Column 1: Core Profile Info */} + +
+
+ {getInitials(category.category_name)} +
+
+
+ {category.category_name} +
+
+
+ + + {/* Column 2: Total Books */} + +
{category.booksCount}
+ + + {/* Column 3: Total Copies */} + +
{category.totalCopies}
+ + + ); + }) + )} + +
- )} -
-
- )} - - {/* 🪟 Modals Infrastructure Overlay Layer Blocks */} - {isCreateOpen && ( -
-
setIsCreateOpen(false)} - /> -
-
-

- Add Category - Classification -

-

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

-
-
- - ) => - setNewCatName(e.target.value) - } - className="w-full px-4 py-2 bg-slate-50 border border-border-main text-text-main rounded-xl text-xs sm:text-sm font-bold focus:bg-card-bg outline-hidden focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" - /> - {createValidationError && ( -

- {createValidationError} -

+ {/* Minimal Pagination Elements */} + {totalPages > 0 && ( +
+ Page {currentPage} of {totalPages} +
+ + +
+
)}
- -
- - -
-
+ )}
- )} +
- setIsDetailsOpen(false)} - category={selectedCategory} - onUpdateName={async (id, name) => { - await updateMutation.mutateAsync({ id, name }); - }} - onDeleteCategory={(cat) => handleTriggerDeleteSequence(cat)} - isMutating={isGlobalProcessing} + {/* Add New Category Custom Overlay Popup */} + setIsAddOpen(false)} + existingCategories={categoriesPayload?.data || []} + onCreateCategory={handleCreateCategory} + isMutating={createCategoryMutation.isPending} /> - - setDeleteConfirmConfig({ isOpen: false, targetCat: null }) - } - onConfirm={() => { - if (deleteConfirmConfig.targetCat) { - deleteMutation.mutate(deleteConfirmConfig.targetCat.category_id); - } - }} - title={`⚠️ Remove ${deleteConfirmConfig.targetCat?.category_name || "Category"} Catalog Cluster?`} - description={`CRITICAL SAFEGUARD WARNING: This operation will permanently delete this category profile along with ALL (${deleteConfirmConfig.targetCat?.booksCount || 0}) underlying book rows and historical circulation logs linked to this ID across the library database! This cannot be undone.`} - confirmText="Confirm Cascade Purge" - variant="danger" - isLoading={deleteMutation.isPending} + {/* Single Dynamic Integrated Modal Drawer Slide Overlay */} + { setIsModalOpen(false); setSelectedCategory(null); }} + category={selectedCategory} + onUpdateName={handleUpdateName} + onDeleteCategory={handleDeleteCategory} + isMutating={updateNameMutation.isPending || deleteCategoryMutation.isPending} />
); -}; +}; \ No newline at end of file diff --git a/client/src/features/categories/types/category.types.ts b/client/src/features/categories/types/category.types.ts index 31dc327..734deb5 100644 --- a/client/src/features/categories/types/category.types.ts +++ b/client/src/features/categories/types/category.types.ts @@ -1,6 +1,7 @@ export interface CategoryMetrics { category_id: string; category_name: string; + totalCopies: number; created_at: string; updated_at: string; booksCount: number; diff --git a/client/src/features/dashboard/components/AmnestySimulator.tsx b/client/src/features/dashboard/components/AmnestySimulator.tsx index 403b53d..6ce55bb 100644 --- a/client/src/features/dashboard/components/AmnestySimulator.tsx +++ b/client/src/features/dashboard/components/AmnestySimulator.tsx @@ -15,13 +15,13 @@ export const AmnestySimulator = ({ 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.

@@ -29,7 +29,7 @@ export const AmnestySimulator = ({
- + Relief Rate: {discount}% @@ -43,11 +43,11 @@ export const AmnestySimulator = ({ 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" + className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-[#1A365D] focus:outline-hidden" />
-
+
Offering this clearance drops{" "} ₹{waivedFine} @@ -57,4 +57,4 @@ export const AmnestySimulator = ({
); -}; +}; \ No newline at end of file diff --git a/client/src/features/dashboard/components/CategoryTreeMap.tsx b/client/src/features/dashboard/components/CategoryTreeMap.tsx index 2d1dd44..1cfbde8 100644 --- a/client/src/features/dashboard/components/CategoryTreeMap.tsx +++ b/client/src/features/dashboard/components/CategoryTreeMap.tsx @@ -17,30 +17,30 @@ export const CategoryTreeMap = ({ const totalValue = categories.reduce((sum, c) => sum + c.value, 0) || 1; return ( -
+
{/* 👋 HIDDEN COMPILER HOOK: Satisfies TS / ESLint "unused variable" rules */} {Object.keys(tailwindColorSafelist).length}
-

- Circulation Share by Genre +

+ 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 */} -
+
))}
@@ -53,11 +53,11 @@ export const CategoryTreeMap = ({ className="flex items-center gap-2 overflow-hidden" > - + {cat.name}{" "} - + ({cat.value}) diff --git a/client/src/features/dashboard/components/CriticalDeficitWidget.tsx b/client/src/features/dashboard/components/CriticalDeficitWidget.tsx index 1cb9967..fedf879 100644 --- a/client/src/features/dashboard/components/CriticalDeficitWidget.tsx +++ b/client/src/features/dashboard/components/CriticalDeficitWidget.tsx @@ -8,19 +8,19 @@ interface DeficitItem { } export const CriticalDeficitWidget = ({ items }: { items: DeficitItem[] }) => ( -
+

Procurement Alerts

-

+

Critical titles with 0 copies remaining on the shelves.

{items.length === 0 ? ( -

+

Zero inventory bottlenecks reported.

) : ( @@ -30,7 +30,7 @@ export const CriticalDeficitWidget = ({ items }: { items: DeficitItem[] }) => ( className="flex justify-between items-center bg-rose-50/40 border border-rose-100 p-2.5 rounded-xl transition-colors" > {item.name} diff --git a/client/src/features/dashboard/components/DeadStockWidget.tsx b/client/src/features/dashboard/components/DeadStockWidget.tsx index 4084752..ed704a9 100644 --- a/client/src/features/dashboard/components/DeadStockWidget.tsx +++ b/client/src/features/dashboard/components/DeadStockWidget.tsx @@ -8,36 +8,36 @@ interface DeadBook { } export const DeadStockWidget = ({ items }: { items: DeadBook[] }) => ( -
+
-

- Relocation "Dead Stock" +

+ Relocation "Dead Stock"

-

+

Inventory titles with zero checkouts over the past 6 months.

{items.length === 0 ? ( -

+

All assets show healthy conversion rates.

) : ( items.map((item) => (
{item.title} - + {/* Shelf: {item.shelf} - + */}
)) )} @@ -47,4 +47,4 @@ export const DeadStockWidget = ({ items }: { items: DeadBook[] }) => ( Optimize Warehouse Storage */}
-); +); \ 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 bc5cb2a..1e95401 100644 --- a/client/src/features/dashboard/components/EngagementLeaderboard.tsx +++ b/client/src/features/dashboard/components/EngagementLeaderboard.tsx @@ -9,35 +9,35 @@ interface TopUser { } export const EngagementLeaderboard = ({ members }: { members: TopUser[] }) => ( -
+
-

+

Elite Reader Engagement

-

+

Top-performing student accounts returning assets on time.

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

+

{member.loans} Loans

@@ -48,8 +48,8 @@ export const EngagementLeaderboard = ({ members }: { members: TopUser[] }) => ( ))}

- {/*
+ {/*
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 843107c..108c50a 100644 --- a/client/src/features/dashboard/components/FineVelocityGauge.tsx +++ b/client/src/features/dashboard/components/FineVelocityGauge.tsx @@ -8,18 +8,18 @@ export const FineVelocityGauge = ({ const total = collected + outstanding || 1; const percentage = Math.round((collected / total) * 100); return ( -
+
-

+

Recovery Collection Velocity

-

+

₹{collected} Recovered

-

+

Remaining Outstanding:{" "} - ₹{outstanding} + ₹{outstanding}

@@ -37,7 +37,7 @@ export const FineVelocityGauge = ({ /> {/* Reactive quantitative value progress track layer */} - + {percentage}%
); -}; +}; \ No newline at end of file diff --git a/client/src/features/dashboard/components/MetricsGrid.tsx b/client/src/features/dashboard/components/MetricsGrid.tsx index 6cb4fc1..da5ca50 100644 --- a/client/src/features/dashboard/components/MetricsGrid.tsx +++ b/client/src/features/dashboard/components/MetricsGrid.tsx @@ -1,85 +1,179 @@ +import { useState, useEffect, useCallback } from "react"; 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: React.ReactNode; +import { + BookOpen, + BookCheck, + Users, + AlertTriangle, + Coins, + ChevronLeft, + ChevronRight, + Info +} from "lucide-react"; + +interface MetricsBannerProps { + data: DashboardSummaryMetrics | undefined; } -const MetricCard = ({ - title, - value, - subtext, - bgClass, - icon, -}: MetricCardProps) => ( -
-
- - {title} - -
- {value} -
- {subtext && ( -

- {subtext} -

- )} -
-
- {icon} -
-
-); +export const MetricsGrid = ({ data }: MetricsBannerProps) => { + const [currentIndex, setCurrentIndex] = useState(0); + + // Configuration for 5 distinct operational contextual slides + const slides = [ + { + title: "Global Catalog Registry", + metricName: "Total Books", + value: data?.totalBooks || 0, + subtext: `Total architectural copies managed: ${data?.totalCopies || 0}`, + icon: , + // Local or fallback high-resolution cinematic background relative paths + bgUrl: "https://images.unsplash.com/photo-1507842217343-583bb7270b66?q=80&w=1600&auto=format&fit=crop", + tagline: "Comprehensive volume control across distributed library collections.", + }, + { + title: "Live Circulation Inventory", + metricName: "Available Books", + value: data?.availableBooks || 0, + subtext: "Ready for immediate member acquisition and processing", + icon: , + bgUrl: "https://images.unsplash.com/photo-1521587760476-6c12a4b040da?q=80&w=1600&auto=format&fit=crop", + tagline: "Real-time verification indices matching current shelf states.", + }, + { + title: "Active Core Patron Base", + metricName: "Active Members", + value: data?.activeMembers || 0, + subtext: "System accounts showing valid active interaction indicators", + icon: , + bgUrl: "https://images.unsplash.com/photo-1529156069898-49953e39b3ac?q=80&w=1600&auto=format&fit=crop", + tagline: "Tracking regular platform use and digital resource checkouts.", + }, + { + title: "Circulation Critical Exceptions", + metricName: "Books Overdue", + value: data?.overdueCount || 0, + subtext: `Current return latency calculation ratio: ${data?.overduePercentage || 0}%`, + icon: , + bgUrl: "https://images.unsplash.com/photo-1516979187457-637abb4f9353?q=80&w=1600&auto=format&fit=crop", + tagline: "Requires prompt systemic notifications and penalty assignment protocols.", + }, + { + title: "Audit Financial Liability Pipeline", + metricName: "Outstanding Fines", + value: `₹${data?.totalOutstandingFines || 0}`, + subtext: "Fixed structural accrual baseline: ₹10/day per item", + icon: , + bgUrl: "https://images.unsplash.com/photo-1554224155-8d04cb21cd6c?q=80&w=1600&auto=format&fit=crop", + tagline: "Aggregated outstanding system accounts receivable ledger metrics.", + }, + ]; + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => (prev === slides.length - 1 ? 0 : prev + 1)); + }, [slides.length]); + + const handlePrev = useCallback(() => { + setCurrentIndex((prev) => (prev === 0 ? slides.length - 1 : prev - 1)); + }, [slides.length]); + + // Synchronous 5-second automatic rotational interval heartbeat execution loops + useEffect(() => { + const autoSlideTimer = setInterval(() => { + handleNext(); + }, 5000); + + return () => clearInterval(autoSlideTimer); + }, [handleNext]); -export const MetricsGrid = ({ - data, -}: { - data: DashboardSummaryMetrics | undefined; -}) => { return ( -
- } - /> - } - /> - } - /> - } - /> - } - /> +
+ + {/* Structural Banner Dynamic Background Image Layout Layers */} + {slides.map((slide, index) => ( +
+ {/* Netflix-Style Double Gradient Vignette Mask Engine */} +
+
+ + {/* Content Overlays Area */} +
+ +
+ + {slide.icon} + + + {slide.title} + +
+ +
+

+ {slide.value} +

+

+ {slide.metricName} +

+
+ +

+ {slide.tagline} +

+ + {slide.subtext && ( +
+ + {slide.subtext} +
+ )} +
+
+ ))} + + {/* Symmetric Left Control Slider Override Button */} + + + {/* Symmetric Right Control Slider Override Button */} + + + {/* Discrete Bottom Linear Micro Progress Navigation Nodes Indicators */} +
+ {slides.map((_, index) => ( +
+
); -}; +}; \ No newline at end of file diff --git a/client/src/features/dashboard/components/OverdueTable.tsx b/client/src/features/dashboard/components/OverdueTable.tsx index 3528946..1dafcae 100644 --- a/client/src/features/dashboard/components/OverdueTable.tsx +++ b/client/src/features/dashboard/components/OverdueTable.tsx @@ -7,8 +7,8 @@ export const OverdueTable = ({ }) => { if (!records || records.length === 0) { return ( -
-

+

+

System verification clear: No overdue inventory items currently registered.

@@ -17,10 +17,10 @@ export const OverdueTable = ({ } return ( -
+
- + @@ -30,22 +30,22 @@ export const OverdueTable = ({ - + {records.map((row) => ( - - - - @@ -62,4 +62,4 @@ export const OverdueTable = ({
Book Title Identifier Borrower Account Reference Expected Return Date
+ {row.title} - + + ID: {row.memberId} {row.borrowerName} + {row.dueDate} @@ -53,7 +53,7 @@ export const OverdueTable = ({ {row.daysLate} days overdue + ₹{row.fineAmount}
); -}; +}; \ No newline at end of file diff --git a/client/src/features/dashboard/components/PeakHoursChart.tsx b/client/src/features/dashboard/components/PeakHoursChart.tsx index b29d615..46022e6 100644 --- a/client/src/features/dashboard/components/PeakHoursChart.tsx +++ b/client/src/features/dashboard/components/PeakHoursChart.tsx @@ -8,36 +8,36 @@ export const PeakHoursChart = ({ }) => { const maxCount = Math.max(...data.map((d) => d.count), 1); return ( -
+
-

- Foot-Traffic +

+ 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}
@@ -45,4 +45,4 @@ export const PeakHoursChart = ({
); -}; +}; \ No newline at end of file diff --git a/client/src/features/dashboard/components/RetentionAnalytics.tsx b/client/src/features/dashboard/components/RetentionAnalytics.tsx index b91f22b..00df078 100644 --- a/client/src/features/dashboard/components/RetentionAnalytics.tsx +++ b/client/src/features/dashboard/components/RetentionAnalytics.tsx @@ -9,39 +9,39 @@ export const RetentionAnalytics = ({ const avg = metrics?.avgDays || 12.4; const maxLimit = metrics?.threshold || 14; return ( -
+
-

- Retention Lifetime +

+ Retention Lifetime Policy

-

+

Average reading span before returning an item.

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

+

Average Book Lifecycle Span

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

+

+

Current Hard Due Limit Threshold

-

+

{maxLimit} Days Authorized

); -}; +}; \ No newline at end of file diff --git a/client/src/features/dashboard/components/ReturnForecaster.tsx b/client/src/features/dashboard/components/ReturnForecaster.tsx index 890721c..fd21231 100644 --- a/client/src/features/dashboard/components/ReturnForecaster.tsx +++ b/client/src/features/dashboard/components/ReturnForecaster.tsx @@ -8,13 +8,13 @@ export const ReturnForecaster = ({ }) => { const maxForecast = Math.max(...forecast.map((f) => f.count), 1); return ( -
+
-

- 7-Day Return +

+ 7-Day Return Flow Forecaster

-

+

Expected book return volumes to optimize intake shelf arrangements.

@@ -26,19 +26,19 @@ export const ReturnForecaster = ({ key={day.date} className="flex items-center gap-4 text-xs font-semibold" > - + {day.date} {/* Horizontal progress channel tracks */} -
+
- + {day.count} items
@@ -46,4 +46,4 @@ export const ReturnForecaster = ({
); -}; +}; \ No newline at end of file diff --git a/client/src/features/fines/components/DeleteFinesModal.tsx b/client/src/features/fines/components/DeleteFinesModal.tsx index d9ab41f..1d384cc 100644 --- a/client/src/features/fines/components/DeleteFinesModal.tsx +++ b/client/src/features/fines/components/DeleteFinesModal.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, X, AlertCircle } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; interface DeleteFinesModalProps { isOpen: boolean; @@ -18,66 +18,78 @@ export const DeleteFinesModal = ({ if (!isOpen) return null; return ( -
-
- {/* Top Close Control */} -
- +
+
+ + {/* Header */} +
+

+ Delete Fine Record +

+ + +
+ + {/* Content */} +
+
+
-
- {/* Branded Alert Icon */} -
- -
+

+ Confirm Fine Deletion +

-

- Delete Fine Record -

+

+ Are you sure you want to permanently remove the fine amount of + + {" "}₹{amount}.00 + + {" "}registered against + + {" "} "{memberName}" + + ? +

-

- Are you sure you want to permanently delete the fine totaling{" "} - - ₹{amount}.00 - {" "} - registered against{" "} - "{memberName}"? -

+ {/* Warning Card */} +
+ + Permanent Action + - {/* Audit Warning Box */} -
- -

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

-
+

+ This operation cannot be undone. The selected fine record will be + permanently removed from the fines ledger, audit history, and + administrative tracking logs. +

+
- {/* Footer Actions */} -
- - -
+ {/* Footer */} +
+ + +
- ); -}; +
+);} \ No newline at end of file diff --git a/client/src/features/fines/components/FineDetailsModal.tsx b/client/src/features/fines/components/FineDetailsModal.tsx index 747f375..c9353ed 100644 --- a/client/src/features/fines/components/FineDetailsModal.tsx +++ b/client/src/features/fines/components/FineDetailsModal.tsx @@ -1,14 +1,5 @@ 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"; interface FineDetailsModalProps { @@ -42,57 +33,57 @@ export const FineDetailsModal = ({ }; return ( - <> -
-
- {/* Header */} -
-
-

- About Fine Context -

-

- ID: FINE-{displayId} -

-
- + <> +
+
+ {/* Header Framework - Matching Reference Module */} +
+
+

+ 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}
@@ -100,34 +91,32 @@ export const FineDetailsModal = ({
{/* Media Asset Section */} -
-

- {" "} +
+ Book Details -

-
+ +
- + Book Title - - {" "} + {fine.bookTitle}
- + Author - + {fine.bookAuthor}
- + Checkout Trigger Date - + {fine.borrowedDate}
@@ -135,57 +124,52 @@ export const FineDetailsModal = ({
{/* Penalty Calculation */} -
-

- {" "} +
+ Penalty Matrix Audit -

-
- + +
+
- + - + - - - {breakdown.outsidePlanDays > 0 && ( - - )} - + - @@ -193,40 +177,42 @@ export const FineDetailsModal = ({
Clause Days Subtotal
+ Standard Plan Rate + {breakdown.withinPlanDays}d + ₹{breakdown.withinPlanFine}.00
- {" "} Out-of-Plan Climax + {breakdown.outsidePlanDays}d + ₹{breakdown.outsidePlanFine}.00
Total Owed Ledger: + ₹{fine.fine_amount}.00
-
- {/* Footer Actions */} -
- - + {/* Operations Layout Action Buttons - Matching layout rules and theme */} +
+ + + +
+
- {/* Confirmation Modal */} - setShowDeleteConfirm(false)} - onConfirm={() => { - onDelete(fine.fine_id); - setShowDeleteConfirm(false); - onClose(); - }} - memberName={fine.memberName} - amount={fine.fine_amount} - /> - - ); + {/* Confirmation Modal */} + setShowDeleteConfirm(false)} + onConfirm={() => { + onDelete(fine.fine_id); + setShowDeleteConfirm(false); + 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 f8c273a..594d397 100644 --- a/client/src/features/fines/components/FinesNotificationBanner.tsx +++ b/client/src/features/fines/components/FinesNotificationBanner.tsx @@ -12,7 +12,7 @@ export const FinesNotificationBanner = ({ if (totalCount === 0) return null; return ( -
+
@@ -33,7 +33,7 @@ export const FinesNotificationBanner = ({ 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 a843340..cba0973 100644 --- a/client/src/features/fines/components/RestoreFineModal.tsx +++ b/client/src/features/fines/components/RestoreFineModal.tsx @@ -17,41 +17,47 @@ export const RestoreFineModal = ({ if (!isOpen || !fine) return null; return ( -
-
-
-
- -
-

- 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. -

-
+
+
+ + {/* Centered Action Icon Container Frame */} +
+ +
+ + {/* Corporate Styled Header Accent Stack */} +

+ Restore Fine Record +

-
- - -
+ {/* Description Context Block */} +

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

+ + {/* Footer Action Control Layout Block Area */} +
+ +
+
- ); +
+); }; diff --git a/client/src/features/fines/components/SettleFinePaymentModal.tsx b/client/src/features/fines/components/SettleFinePaymentModal.tsx index c916e0f..0cb02e4 100644 --- a/client/src/features/fines/components/SettleFinePaymentModal.tsx +++ b/client/src/features/fines/components/SettleFinePaymentModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import type { FineRecord } from "../../../types/fines"; -import { X, Calendar, CheckSquare } from "lucide-react"; +import { Calendar, CheckSquare } from "lucide-react"; interface SettleFinePaymentModalProps { isOpen: boolean; @@ -37,98 +37,109 @@ export const SettleFinePaymentModal = ({ }); }; - return ( -
-
- {/* Header */} -
-

+ return ( +
+
+ {/* Header Framework - Matching Reference Module */} +
+
+

Process Fine Settlement

-
+ +
-
- {/* Quick Informational Vector */} -
-
- Account Name: - - {fine.memberName} - -
-
- Balance Due: - - ₹{fine.fine_amount}.00 - -
+ + {/* Quick Informational Vector */} +
+
+ Account Name: + + {fine.memberName} +
- - {/* Payment Method Selector Segment */} -
- -
- {(["CASH", "CARD", "UPI"] as const).map((method) => ( - - ))} -
+
+ Balance Due: + + ₹{fine.fine_amount}.00 +
+
- {/* Payment Calendar Target Input */} -
- -
- setSelectedPaidDate(e.target.value)} - className="w-full pl-9 pr-4 py-2.5 bg-slate-50 border border-border-main rounded-xl text-xs font-medium text-text-main placeholder:text-slate-400 outline-hidden focus:bg-card-bg focus:border-slate-900 transition-all cursor-pointer font-mono" - /> - -
-

- *Backdating is permitted for missed database updates. Future dates - remain locked. -

+ {/* Payment Method Selector Segment */} +
+ +
+ {(["CASH", "CARD", "UPI"] as const).map((method) => ( + + ))}
+
-
- + {/* Payment Calendar Target Input */} +
+ +
+ setSelectedPaidDate(e.target.value)} + className="w-full pl-9 pr-4 py-2.5 bg-slate-50 border border-gray-200 rounded-xl text-xs font-semibold text-[#2D3748] placeholder:text-[#718096] outline-none focus:bg-white focus:border-[#1A365D] transition-all cursor-pointer" + /> +
- -
+

+ *Backdating is permitted for missed database updates. Future dates + remain locked. +

+
+ + {/* Action Button Layout Framework */} +
+ + + +
+
- ); +
+); }; diff --git a/client/src/features/fines/pages/FinesPage.tsx b/client/src/features/fines/pages/FinesPage.tsx index 1f0d86b..049d3b6 100644 --- a/client/src/features/fines/pages/FinesPage.tsx +++ b/client/src/features/fines/pages/FinesPage.tsx @@ -13,19 +13,18 @@ import { RestoreFineModal } from "../components/RestoreFineModal"; // Lucide Icons import { Search, - Filter, - ChevronLeft, - ChevronRight, ShieldAlert, + ChevronDown, History, BookOpen, AlertCircle, AlertTriangle, RefreshCw, + RotateCcw, + // X, User, CreditCard, CheckCircle2, - RotateCcw, } from "lucide-react"; interface AxiosErrorResponse { @@ -40,6 +39,9 @@ export const FinesPage = () => { const queryClient = useQueryClient(); const [selectedFine, setSelectedFine] = useState(null); const [showRestoreModal, setShowRestoreModal] = useState(false); + const [activeHeaderDropdown, setActiveHeaderDropdown] = useState< + "delay" | null +>(null); // Active View Tab Panel Layout Selector ("active" | "history") const [activeTab, setActiveTab] = useState<"active" | "history">("active"); @@ -178,14 +180,13 @@ export const FinesPage = () => { }; return ( -
- {/* Dynamic Header View Deck */} -
-
-

- Fines Management Desk +
{/* Dynamic Header View Deck */} +
+
+

+ Fines Management Desk

-

+

Realtime data syncing. Automatic accrual rates apply dynamically at 12:00 AM nightly: Active Plans (₹10/day) | Expired Plans (₹20/day).

@@ -198,7 +199,7 @@ export const FinesPage = () => { onClick={() => handleTabChange("active")} className={`flex items-center gap-1.5 px-3.5 py-2 text-xs font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer ${ activeTab === "active" - ? "bg-card-bg text-text-main shadow-xs" + ? "bg-card-bg shadow-xs" : "text-slate-500 hover:text-slate-800" }`} > @@ -209,7 +210,7 @@ export const FinesPage = () => { onClick={() => handleTabChange("history")} className={`flex items-center gap-1.5 px-3.5 py-2 text-xs font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer ${ activeTab === "history" - ? "bg-card-bg text-text-main shadow-xs" + ? "bg-card-bg shadow-xs" : "text-slate-500 hover:text-slate-800" }`} > @@ -245,66 +246,60 @@ export const FinesPage = () => { )} {/* Search Filter Control Grid */} - {/* Search Filter Control Grid */} -
- {/* 🔍 Left Side: Dynamic Text Search Bar */} -
- +
+
+
+
+ + + + { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-border-main rounded-xl text-xs sm:text-sm font-medium text-text-main placeholder:text-slate-400 outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all" - /> - -
- - {/* 🎛️ Right Side: Filters and Reset Actions Group */} - {activeTab === "active" && ( -
- {/* 📋 Select Box Component Wrapper */} -
- - -
-
- - {/* 🔄 Reset Button - Safely Positioned to the Right Side */} + {/* {searchQuery && ( -
- )} -
+ )} */} +
+ + +
+ +
+
+ {/* pasted */} + + {/* Central Interactive Data Core Grid Matrix */} {isLoading ? ( @@ -313,28 +308,117 @@ export const FinesPage = () => { Syncing Master Banking Ledger Channels...
) : ( -
-
- - - -
+
+
+ + + + - - - - + + + - - {paginatedRowsData.length === 0 ? ( + {paginatedRowsData.length === 0 ? ( setSelectedFine(fine)} - className="hover:bg-slate-50/80 transition-colors cursor-pointer select-none" - > - - - - - + )) @@ -415,35 +501,34 @@ export const FinesPage = () => { {/* Pagination Command Module */} -
- +
Page {currentPage} / {totalPagesCount}{" "} | Total{" "} {totalItemsCount} Fines
- + type="button" + disabled={currentPage === 1} + onClick={(e) => { + e.stopPropagation(); + setCurrentPage((p) => p - 1); + }} + className="text-gray-600 font-semibold tracking-wider disabled:opacity-20 cursor-pointer hover:text-[#2B6CB0] flex items-center gap-1 transition-colors" +> + ← Previous + +
diff --git a/client/src/features/issues/components/DeleteTransactionModal.tsx b/client/src/features/issues/components/DeleteTransactionModal.tsx index 1a413a6..ff49d54 100644 --- a/client/src/features/issues/components/DeleteTransactionModal.tsx +++ b/client/src/features/issues/components/DeleteTransactionModal.tsx @@ -1,4 +1,4 @@ -import { Trash2, AlertOctagon, X } from "lucide-react"; +import { Trash2, AlertOctagon } from "lucide-react"; interface DeleteModalProps { isOpen: boolean; @@ -18,77 +18,97 @@ export const DeleteTransactionModal = ({ if (!isOpen) return null; return ( -
-
- {/* Warning Indicator Context Accent Strip */} -
-
-
- {mode === "SINGLE" ? ( - - ) : ( - - )} -
-
-

- {mode === "SINGLE" - ? "Purge Circulation Record" - : "Purge Completed History"} -

-
-
- -
+
+
- {/* Informational Core Content block */} -
-

+ {/* Header */} +

+
+
{mode === "SINGLE" ? ( - <> - Are you sure you want to delete this{" "} - issue_book{" "} - data file for{" "} - - "{titleDetails}" - - ? - + ) : ( - "Are you sure you want to permanently delete all circulation entries with a RETURNED status from the system registry?" + )} -

+
-
- ⚠️ Warning: This administrative destruction execution cannot be - undone. System database records will alter instantly. +
+

+ {mode === "SINGLE" + ? "Delete Circulation Record" + : "Delete Returned History"} +

- {/* Control Desk Actions Footer */} -
- - + +
+ + {/* Content */} +
+

+ {mode === "SINGLE" ? ( + <> + Are you sure you want to permanently remove the circulation + record for + + {" "} + "{titleDetails}" + + ? + + ) : ( + <> + Are you sure you want to permanently remove all circulation + records that currently have a + + {" "} + RETURNED + + {" "}status? + + )} +

+ + {/* Warning Card */} +
+ + Permanent Action + + +

+ This operation cannot be undone. Deleted circulation records will + be permanently removed from the system database and administrative + audit history. +

+ + {/* Footer */} +
+ + + +
- ); +
+); }; diff --git a/client/src/features/issues/components/IssueDetailsModal.tsx b/client/src/features/issues/components/IssueDetailsModal.tsx index e83e8ed..751a56b 100644 --- a/client/src/features/issues/components/IssueDetailsModal.tsx +++ b/client/src/features/issues/components/IssueDetailsModal.tsx @@ -6,7 +6,6 @@ import { useNavigate } from "react-router-dom"; // For redirecting to payments // Lucide Icons for the professional popup layout import { - ShieldAlert, AlertTriangle, ArrowRight, X, @@ -73,133 +72,138 @@ export const IssueDetailsModal = ({ } }; - return ( + return ( <> - {/* Primary Issue Details Window Desk Layer */} -
-
- {/* Header Block Panel */} -
+ {/* Primary Issue Details Window Desk Layer - Updated with reference background, text colors, and font properties */} +
+
+ + {/* Header Block Panel - Matched with reference framework layout */} +
-

+

Issue Details -

- - {formattedIssueId} - -
-
- + +

+ ID: {formattedIssueId} +

+
-
- {/* Member Meta Information Sub-Card */} -
-
- - - Profile Account Context - -
-
- {record.memberName} -
-
- ✉️ {record.memberEmail || "No Email Provided"} -
-
- 📞 {record.memberPhone || "No Phone Contact Registered"} -
- -
- - Active Resource Holdings:{" "} - {isLoadingStats ? ( - - Calculating... - - ) : ( - - {memberStats?.currentBorrows ?? 0} books outstanding - - )} - -
-
+ {/* Detailed Metadata Body Context Frame - Upgraded text-colors and layout spacing rules */} +
+
+ + {/* Member Meta Information Sub-Card */} +
+
+ + + Profile Account Context + +
+
+ {record.memberName} +
+
+ ✉️ {record.memberEmail || "No Email Provided"} +
+
+ 📞 {record.memberPhone || "No Phone Contact Registered"} +
- {/* Book Catalog Details Section */} -
-
- - - Checked Inventory Volume - -
-
- 📖 {record.bookTitle} -
-
- Catalog Author: {record.bookAuthor || "Unknown Reference"} +
+ + Active Resource Holdings:{" "} + {isLoadingStats ? ( + + Calculating... + + ) : ( + + {memberStats?.currentBorrows ?? 0} books outstanding + + )} + +
-
- {/* Timeline Parameters Matrix */} -
-
-
- - - Checkout Signature + {/* Book Catalog Details Section */} +
+
+ + + Checked Inventory Volume
-
- {record.borrowedDate} +
+ 📖 {record.bookTitle} +
+
+ Catalog Author: {record.bookAuthor || "Unknown Reference"}
-
-
- - - Target Return Due - + + {/* Timeline Parameters Matrix */} +
+
+
+ + + Checkout Signature + +
+
+ {record.borrowedDate} +
-
- {record.dueDate} +
+
+ + + Target Return Due + +
+
+ {record.dueDate} +
-
- {/* Control Terminal Footer */} -
- -
- + {/* Control Terminal Footer - Using matching layout rules and primary brand button colors */} +
+
+ + +
+
@@ -207,73 +211,83 @@ export const IssueDetailsModal = ({ {/* 🟢 NEW SECONDARY PORTAL LAYER: Professional Unpaid Fine Blocking Warning Pop-Up */} {showFineBlockModal && ( -
-
+
+
+ {/* Warning Header block matches standard system alerts */} -
-
- -
+
-

+

Return Blocked: Pending Balance

-

+

Financial Validation Exception Bound

+
{/* Warning Body Parameters */} -
-

+

+

The library core system cannot authorize this inventory shelf check-in sequence because an unpaid fine liability matches this active operation.

{/* Data Summary Box */} -
+
- + Account Holder: - + {record.memberName}
- + Asset Volume: - + {record.bookTitle}
-
+
Overdue Debt: - + ₹{record.fineAmount}.00
-

- Policy Rule: Outstanding debt liabilities must clear through the - cash registration counter desk before restoring book items back - into system catalog slots. -

+ {/* Policy Card Rule layout alignment with reference parameters */} +
+ + Policy Rule Verification + +

+ Outstanding debt liabilities must clear through the cash registration counter desk + before restoring book items back into system catalog slots. +

+
{/* Footer Control Panel */} -
+
@@ -281,14 +295,15 @@ export const IssueDetailsModal = ({ type="button" onClick={() => { setShowFineBlockModal(false); - onClose(); // Close the parent transaction modal - navigate("/fines"); // Smoothly redirect the librarian to collect the cash + onClose(); + navigate("/fines"); }} - className="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs uppercase tracking-wider rounded-xl shadow-xs flex items-center gap-1.5 transition-all cursor-pointer" + className="px-5 py-2.5 bg-rose-600 hover:bg-rose-700 text-white text-xs font-bold rounded-full transition-all cursor-pointer shadow-sm text-center tracking-wide inline-flex items-center gap-1.5" > Collect Fine Counter
+
)} diff --git a/client/src/features/issues/components/TransactionModal.tsx b/client/src/features/issues/components/TransactionModal.tsx index bf35a7d..1eff3fb 100644 --- a/client/src/features/issues/components/TransactionModal.tsx +++ b/client/src/features/issues/components/TransactionModal.tsx @@ -241,38 +241,38 @@ export const TransactionModal = ({ }); }; - return ( -
-
- {/* HEADER BLOCK BRANDED SYSTEM */} -
+ return ( +
+
+ {/* HEADER BLOCK BRANDED SYSTEM - Matched with reference framework layout */} +
-

- Circulation Terminal +

+ {editingRecord ? "Edit Issue Details" : "Issue New Book"}

-

- {editingRecord ? "Edit issue details" : "Issue New Book"} -

+

+ Circulation Terminal Desk Operation +

-
+ {/* 1. MEMBER LOOKUP INPUT SECTION */}
-
Account Holder Member + Media Asset Context - {activeTab === "active" ? "Delayed Days" : "Settled Scope"} - Accrued AmountPlan Clause + + + {activeHeaderDropdown === "delay" && ( +
+ + + + + + + +
+ )} +
Fine AmountPlan Clause
{
-
+ className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" > +
+
{fine.memberName}
{fine.memberEmail}
+
{ /> {fine.bookTitle}
- + Due Date:{" "} {fine.actualReturnDueDate || fine.actualReturnDate || "N/A"}
+ {activeTab === "active" ? ( - + {fine.delayed_days} Days Overdue ) : ( - + Paid ({fine.paidDate || fine.paid_date || "Settled"} ) )} + ₹{fine.fine_amount}.00 {fine.paymentMethod && ( - + via {fine.paymentMethod} )} - + + + {fine.membershipActive ? "Active Plan" : "Plan Expired"} +
- - - - - - - + {/* Main List Workspace Table */} +
+ +
+
- - Member Name - - - Issued Book Title - - - Checkout Date - Target Due Deadline - - Status Flag -
+ + + + + + + + + + + + + + + + {paginatedRecords.length === 0 ? ( + + - - - {paginatedRecords.length === 0 ? ( - - { + setSelectedRecord(record); + setIsDetailsOpen(true); + }} + className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" + > + + + + + + + + + - ) : ( - paginatedRecords.map((record) => ( - { - setSelectedRecord(record); - setIsDetailsOpen(true); - }} - className="hover:bg-slate-50/80 transition-colors cursor-pointer select-none group" - > - - - - - - - )) - )} - -
+ + Member Name + + + Issued Book Title + + + Checkout Date + + Target Due Deadline + +
+ + + +
+
+ No active out-of-building book logs registered on + current indexing criteria. +
- No active out-of-building book logs registered on - current indexing criteria. + ) : ( + paginatedRecords.map((record) => ( +
+ {record.memberName} + + {record.bookTitle} + + {record.borrowedDate} + + {record.dueDate} + + + {/* {record.computedStatus === "OVERDUE" && ( + + )} */} + + {record.computedStatus} +
- {record.memberName} - - {record.bookTitle} - - {record.borrowedDate} - - {record.dueDate} - - - {record.computedStatus === "OVERDUE" && ( - - )} - {record.computedStatus} - -
-
+ )) + )} + +
+
- {totalPages > 0 && ( -
- - Page {currentPage} / {totalPages}{" "} - | Total{" "} - {totalRecordsCount} Books + {totalPages > 0 && ( +
+ + Page{" "} + + {currentPage} + {" "} + of{" "} + + {totalPages} -
- - -
+ + | + + Total{" "} + + {totalRecordsCount} + {" "} + Books +
+ +
+ + +
- )} -
+
+ )}
- )} - - {/* Modals Layers */} - setIsFormOpen(false)} - onSubmit={(vals) => saveMutation.mutate(vals)} - editingRecord={selectedRecord} - /> - - setIsDetailsOpen(false)} - record={selectedRecord} - onMarkAsReturned={(id) => returnBookMutation.mutate(id)} - onTriggerEdit={() => { - setIsDetailsOpen(false); - setIsFormOpen(true); - }} - /> -
- ); +
+ )} + + {/* Modals Layers */} + setIsFormOpen(false)} + onSubmit={(vals) => saveMutation.mutate(vals)} + editingRecord={selectedRecord} + /> + + setIsDetailsOpen(false)} + record={selectedRecord} + onMarkAsReturned={(id) => returnBookMutation.mutate(id)} + onTriggerEdit={() => { + setIsDetailsOpen(false); + setIsFormOpen(true); + }} + /> +
+); }; diff --git a/client/src/features/members/components/DeleteConfirmationModal.tsx b/client/src/features/members/components/DeleteConfirmationModal.tsx index 9a116b0..05ef20e 100644 --- a/client/src/features/members/components/DeleteConfirmationModal.tsx +++ b/client/src/features/members/components/DeleteConfirmationModal.tsx @@ -16,22 +16,22 @@ export const DeleteConfirmationModal = ({ return (
{/* Container: Changed to an off-white/ivory-tint base with a soft linen-amber border */} -
+
{/* Header: Shifted from text-sm to text-base, using a deeper, slate-ink tone */} -

+

Confirm Member Deletion

{/* Main Paragraph: Softened slightly to slate-600 for optimal readability */} -

+

Are you sure you want to delete the library member record for{" "} - {memberName}? + {memberName}?

{/* Callout Block: Shifted to a premium cream/rose warning surface with refined typography rules */} -
- +
+ Notice: This action removes the library member profile tier assignment only. @@ -40,12 +40,12 @@ export const DeleteConfirmationModal = ({
{/* Modal Action Footers - Clear Call to Action */} -
+
{/* Cancel Button: Crisp off-white tactile styling */} @@ -53,8 +53,7 @@ export const DeleteConfirmationModal = ({
diff --git a/client/src/features/members/components/MemberDetailsModal.tsx b/client/src/features/members/components/MemberDetailsModal.tsx index 8368c99..65c52ed 100644 --- a/client/src/features/members/components/MemberDetailsModal.tsx +++ b/client/src/features/members/components/MemberDetailsModal.tsx @@ -1,15 +1,25 @@ import React, { useState } from "react"; -import type { LibraryMember, MembershipPlan } from "../../../types/members"; +import type { LibraryMember, MembershipPlan, SystemUser } from "../../../types/members"; import { DeleteConfirmationModal } from "./DeleteConfirmationModal"; +import { MemberModal } from "./MemberModal"; +import type { MemberFormValues } from "../schemas/memberSchema"; interface MemberDetailsModalProps { isOpen: boolean; onClose: () => void; member: LibraryMember | null; - plans: MembershipPlan[]; + plans: { + data: MembershipPlan[]; + meta?: { + globalActiveMembers?: number; + globalInactiveMembers?: number; + total?: number; + }; + } | MembershipPlan[]; onRenew: (planId: string) => void; onDelete: (id: string) => void; isRenewing: boolean; + users?: SystemUser[]; } export const MemberDetailsModal: React.FC = ({ @@ -19,209 +29,158 @@ export const MemberDetailsModal: React.FC = ({ plans, onRenew, onDelete, - isRenewing, + users = [], }) => { const [showRenewalScreen, setShowRenewalScreen] = useState(false); - const [selectedPlanId, setSelectedPlanId] = useState(""); - // Controls the conditional view overlays of the delete modal safely const [isDeleteOpen, setIsDeleteOpen] = useState(false); if (!isOpen || !member) return null; - const handleRenewSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!selectedPlanId) return; - onRenew(selectedPlanId); + // Defensive array handling matching parent layouts safely + const safePlans = Array.isArray(plans) ? plans : []; + + // Handle the standardized submit handler matching react-hook-form schema expectations + const handleRenewSubmit = (formData: MemberFormValues) => { + if (!formData.membershipPlanId) return; + onRenew(formData.membershipPlanId); setShowRenewalScreen(false); - setSelectedPlanId(""); }; const handleConfirmDelete = () => { onDelete(member.id); - setIsDeleteOpen(false); // Close the sub-modal overlay - onClose(); // Close the master profile drawer together + setIsDeleteOpen(false); + onClose(); }; + /* SCREEN B Variant: If the renewal action layout flag is tripped, + we render the full specialized unified MemberModal layout right in place. + */ + if (showRenewalScreen) { + return ( + setShowRenewalScreen(false)} + onSubmit={handleRenewSubmit} + plans={safePlans} + users={users} + editingMember={member} // Pass the member object here to safely trigger the Renew parameters + /> + ); + } + return ( <> - {/* High contrast bright layout backdrops with light frosting filters */} -
-
- {/* Header Grid Framework - Clean Bright Banner */} -
-

- {showRenewalScreen ? "Renew Membership Plan" : "Member Details"} + {/* High-contrast background overlay with clean backdrop filter */} +
+
+ + {/* Header Framework - Matching Manage Page Depth */} +
+

+ Member Details

- {/* Content Box Switcher Container */} -
- {!showRenewalScreen ? ( - /* SCREEN A: DETAILED ACCOUNT OVERVIEW INFO CARD */ -
-
-
-

- {member.name} -

-

- ID: REGISTER- - {member.id - ? member.id.split("-").pop()?.slice(-4).toUpperCase() - : "0000"} -

-
- - {member.isActive ? "Plan: Active" : "Plan: Expired"} - + {/* SCREEN A: DETAILED ACCOUNT OVERVIEW INFO CARD */} +
+
+
+
+

+ {member.name} +

+

+ ID: REGISTER- + {member.id + ? member.id.split("-").pop()?.slice(-4).toUpperCase() + : "0000"} +

+ + + {member.isActive ? "Active" : "Expired"} + +
-
+
-
-
- - Email Address - - - {member.email} - -
-
- - Phone Number - - - {member.phoneNumber || "No Verified Phone"} - -
-
- - Current Active Plan - -
- - {member.membershipPlanName} - -
-
-
- - Plan Expiry Date - - - {member.expiryDate} +
+
+ + Email Address + + + {member.email} + +
+
+ + Phone Number + + + {member.phoneNumber || "—"} + +
+
+ + Current Active Plan + +
+ + {member.membershipPlanName}
- - {/* Operations Layout Interface */} -
- - - -
-
- ) : ( - /* SCREEN B: DYNAMIC RENEWAL INPUT SCREEN */ - -
- You are renewing the subscription plan for{" "} - - {member.name} +
+ + Plan Expiry Date + + + {member.expiryDate} - . Submitting this update logs the start date as{" "} - Today and - automatically assigns the corresponding contract validation - cycles.
+
-
- - -
+ {/* Operations Layout Action Buttons */} +
+ - {/* Renewal Control Actions */} -
- - -
- - )} + +
+
- {/* CUSTOM DELETE CONFIRMATION INTERFACE MODAL OVERLAY */} + {/* OVERLAY INTERFACE SUB-MODAL */} setIsDeleteOpen(false)} @@ -230,4 +189,4 @@ export const MemberDetailsModal: React.FC = ({ /> ); -}; +}; \ No newline at end of file diff --git a/client/src/features/members/components/MemberModal.tsx b/client/src/features/members/components/MemberModal.tsx index e842cee..60e1ffd 100644 --- a/client/src/features/members/components/MemberModal.tsx +++ b/client/src/features/members/components/MemberModal.tsx @@ -16,7 +16,14 @@ interface MemberModalProps { onClose: () => void; onSubmit: (data: MemberFormValues) => void; users: SystemUser[]; - plans: MembershipPlan[]; + plans: { + data: MembershipPlan[]; + meta?: { + globalActiveMembers?: number; + globalInactiveMembers?: number; + total?: number; + }; + } | MembershipPlan[]; editingMember?: LibraryMember | null; } @@ -24,8 +31,8 @@ export const MemberModal = ({ isOpen, onClose, onSubmit, - users, - plans, + users = [], + plans = [], editingMember, }: MemberModalProps) => { const { @@ -46,14 +53,16 @@ export const MemberModal = ({ }, }); + console.log("plans",plans); + const selectedUserId = useWatch({ control, name: "userId", }); - // 1. Autofill user details AND clear them out safely if user choice becomes empty + // Autofill user details safely if matching configuration exists useEffect(() => { - if (!editingMember) { + if (!editingMember && Array.isArray(users)) { if (selectedUserId) { const selectedUser = users.find((u) => u.id === selectedUserId); if (selectedUser) { @@ -67,7 +76,7 @@ export const MemberModal = ({ } }, [selectedUserId, users, setValue, editingMember]); - // 2. Clear/Reset form completely every single time the modal opens or shifts modes + // Reset form properties context-safely when changing state modes useEffect(() => { if (isOpen) { if (editingMember) { @@ -88,24 +97,38 @@ export const MemberModal = ({ }); } } - }, [isOpen, editingMember, reset]); + }, [isOpen, editingMember, reset]); // Fixed typo here (removed 'tracks') if (!isOpen) return null; - // Check if there are no available users left to turn into subscriber members - const hasNoAvailableUsers = users.length === 0; + // Safeguard array checks before evaluating lengths and mappings + const safeUsers = Array.isArray(users) ? users : []; + + + // Cleaned Adaptive Extraction Engine to correctly resolve flat arrays or sub-objects + const safePlans: MembershipPlan[] = Array.isArray(plans) + ? plans + : (plans && typeof plans === "object" && "data" in plans && Array.isArray(plans.data)) + ? plans.data + : []; + + console.log("safeplans configuration payload verified:", safePlans); + + const hasNoAvailableUsers = safeUsers.length === 0; return ( -
-
- {/* Modal Branding Header - Clean Light Structured Banner */} -
-

- {editingMember ? "Renew Membership Plan" : "Add New Member"} +
+
+ + {/* Modal Branding Header Block */} +
+

+ {editingMember ? `Renew Membership — ${editingMember.name}` : "Add New Member"}

@@ -113,132 +136,115 @@ export const MemberModal = ({
- {/* Form Control: User Selection Selector dropdown */} -
- - - {errors.userId && ( -

- {errors.userId.message} -

- )} -
+ {/* ==================== ADD MODE ELEMENTS ONLY ==================== */} + {!editingMember && ( + <> + {/* Form Input: Library User Profiling Dropdown */} +
+ + + {errors.userId && ( +

+ {errors.userId.message} +

+ )} +
- {/* Form Control Block Grid Row (Read Only Meta Profiles) */} -
-
- - -
-
- - -
-
+ {/* Form Meta Display Grid (Auto Filled - Read Only) */} +
+
+ + +
+
+ + +
+
+ + )} - {/* Form Control: Membership tier select index */} + {/* ==================== CORE PLAN CONFIG FIELD (SHARED/RENEW MODE) ==================== */}
-
- {/* Form Control: Continuous Activation Verification parameters */} - {editingMember && ( -
-
- - Membership Continuity Toggle - - - Updating values shifts account validation cycles to today's - parameters. - -
- -
- )} - - {/* Action Footer Frame */} -
+ {/* Action Operations Footer Segment */} +
); -}; +}; \ No newline at end of file diff --git a/client/src/features/members/pages/MembersPage.tsx b/client/src/features/members/pages/MembersPage.tsx index 55759db..fbf018d 100644 --- a/client/src/features/members/pages/MembersPage.tsx +++ b/client/src/features/members/pages/MembersPage.tsx @@ -1,63 +1,80 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { axiosClient } from "../../../api/axiosClient"; -import { MemberDetailsModal } from "../components/MemberDetailsModal"; import { MemberModal } from "../components/MemberModal"; -import type { - LibraryMember, - MembershipPlan, - SystemUser, -} from "../../../types/members"; +import { MemberDetailsModal } from "../components/MemberDetailsModal"; +import type { LibraryMember, MembershipPlan, SystemUser } from "../../../types/members"; import type { MemberFormValues } from "../schemas/memberSchema"; import { toast } from "sonner"; import { useAuthStore } from "../../../store/authStore"; import { Plus, Search, - ChevronLeft, - ChevronRight, - ChevronDown, RotateCcw, X, + Users, + ChevronDown } from "lucide-react"; export const MembersPage = () => { const queryClient = useQueryClient(); - // Server-driven lookup filter parameters + // Search filter parameters const [currentPage, setCurrentPage] = useState(1); const [searchTerm, setSearchTerm] = useState(""); const [tierFilter, setTierFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); - // Card Overlay state tracking management - const [isDetailsOpen, setIsDetailsOpen] = useState(false); + // Clean UI state parameters + const [activeHeaderDropdown, setActiveHeaderDropdown] = useState<"plan" | "status" | null>(null); + + // Modal controllers const [isFormOpen, setIsFormOpen] = useState(false); - const [selectedMember, setSelectedMember] = useState( - null, - ); + const [selectedMember, setSelectedMember] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); const token = useAuthStore((state) => state.token); + + // Refs for tracking outside dropdown clicks + const planDropdownRef = useRef(null); + const statusDropdownRef = useRef(null); + + // Close interactive headers if clicking outside + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + activeHeaderDropdown === "plan" && + planDropdownRef.current && + !planDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + if ( + activeHeaderDropdown === "status" && + statusDropdownRef.current && + !statusDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [activeHeaderDropdown]); - // Core background querying pipeline mapping directly to server indices + // Core background querying pipeline matching data schema rows const { data: membersPayload, isLoading } = useQuery<{ total: number; + globalActive: number; + globalExpired: number; data: LibraryMember[]; }>({ - queryKey: [ - "membersListFeed", - token, - currentPage, - searchTerm, - tierFilter, - statusFilter, - ], + queryKey: ["membersListFeed", token, currentPage, searchTerm, tierFilter, statusFilter], queryFn: async () => { const res = await axiosClient.get("/members", { params: { page: currentPage, - limit: 10, + limit: 1000, search: searchTerm || undefined, plan: tierFilter || undefined, status: statusFilter || undefined, @@ -67,56 +84,49 @@ export const MembersPage = () => { const rootData = res.data?.data || res.data; const rawRecords = rootData?.data || []; const totalCount = rootData?.meta?.total || 0; + + const globalActiveCount = rootData?.meta?.globalActive ?? "-"; + const globalExpiredCount = rootData?.meta?.globalExpired ?? "-"; const transformed = Array.isArray(rawRecords) ? rawRecords.map((dbRow: unknown): LibraryMember => { const row = dbRow as Record; const userObj = (row.user || {}) as Record; - const planObj = (row.membership_plan || {}) as Record< - string, - unknown - >; + const planObj = (row.membership_plan || {}) as Record; return { id: String(row.member_id || row.id || ""), userId: String(row.user_id || row.userId || ""), name: String(userObj.name || "Unknown Member"), email: String(userObj.gmail || "No Email Registered"), - phoneNumber: String( - userObj.phone_number || row.phoneNumber || "", - ), - membershipPlanId: String( - row.membership_plan_id || row.membershipPlanId || "", - ), - membershipPlanName: String( - planObj.plan_name || "No Plan Tier Assigned", - ), - activationDate: String( - row.activation_date || row.activationDate || "", - ), + phoneNumber: String(userObj.phone_number || row.phoneNumber || ""), + membershipPlanId: String(row.membership_plan_id || row.membershipPlanId || ""), + membershipPlanName: String(planObj.plan_name || "No Plan Tier Assigned"), + activationDate: String(row.activation_date || row.activationDate || ""), expiryDate: String(row.expiry_date || row.expiryDate || "N/A"), isActive: row.membership_status === "ACTIVE", }; }) : []; - return { total: totalCount, data: transformed }; + return { total: totalCount, globalActive: globalActiveCount, globalExpired: globalExpiredCount, data: transformed }; }, enabled: !!token, }); - // Pull plan catalogs for layout dropdown filtering selectors - const { data: plans = [] } = useQuery({ + // Safe destructuring with fallback array + const { data: rawPlans } = useQuery({ queryKey: ["membershipPlansFeed", token], queryFn: async () => { - const res = await axiosClient.get("/members/plans"); + const res = await axiosClient.get("/members/dropdown"); + console.log("dropdown",res); return res.data?.data || res.data || []; }, enabled: !!token, }); - // Fetching profiles to load form modal registration selectors - const { data: users = [] } = useQuery({ + // Safe destructuring with fallback array + const { data: rawUsers } = useQuery({ queryKey: ["eligibleUsersList", token], queryFn: async () => { const res = await axiosClient.get("/members/available-users"); @@ -125,7 +135,10 @@ export const MembersPage = () => { enabled: !!token, }); - // Create or Update Member Pipeline Mutation + // 🛡️ CRITICAL DEFENSIVE SANITIZATION LAYER TO ASSURE AN ARRAY TYPE IS ALWAYS APPLIED + const plans = Array.isArray(rawPlans) ? rawPlans : []; + const users = Array.isArray(rawUsers) ? rawUsers : []; + const saveMemberMutation = useMutation({ mutationFn: async (payload: MemberFormValues) => { const processedPayload = { @@ -135,336 +148,360 @@ export const MembersPage = () => { }; if (selectedMember) { - return await axiosClient.patch( - `/members/${selectedMember.id}`, - processedPayload, - ); + return await axiosClient.patch(`/members/${selectedMember.id}`, processedPayload); } return await axiosClient.post("/members", processedPayload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["membersListFeed"] }); - queryClient.invalidateQueries({ queryKey: ["eligibleUsersList"] }); - - toast.success( - selectedMember - ? "Membership renewed cleanly." - : "New library member created successfully!", - ); + toast.success(selectedMember ? "Membership updated cleanly." : "New library member created!"); setIsFormOpen(false); setSelectedMember(null); }, onError: (error: unknown) => { - let serverErrorMessage = - "Database schema validation failed on submission updates."; - if (error instanceof AxiosError) { - serverErrorMessage = - error.response?.data?.message || serverErrorMessage; - } - toast.error(serverErrorMessage); + let msg = "Database mutation failure."; + if (error instanceof AxiosError) msg = error.response?.data?.message || msg; + toast.error(msg); }, }); - // Renew details card mutation mapping updates safely const renewMutation = useMutation({ - mutationFn: async ({ - memberId, - planId, - }: { - memberId: string; - planId: string; - }) => { - return await axiosClient.patch(`/members/${memberId}`, { - membership_plan_id: planId, - }); + mutationFn: async (planId: string) => { + if (!selectedMember) return; + return await axiosClient.patch(`/members/${selectedMember.id}`, { membership_plan_id: planId }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["membersListFeed"] }); toast.success("Membership activated successfully!"); - setIsDetailsOpen(false); + setIsDetailsOpen(false); setSelectedMember(null); }, - onError: (error: unknown) => { - let serverErrorMessage = "Database contract update failed."; - if (error instanceof AxiosError) { - serverErrorMessage = - error.response?.data?.message || serverErrorMessage; - } - toast.error(serverErrorMessage); - }, }); - // Delete account profile dataset logs mutation const deleteMutation = useMutation({ mutationFn: async (memberId: string) => { return await axiosClient.delete(`/members/${memberId}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["membersListFeed"] }); - queryClient.invalidateQueries({ queryKey: ["eligibleUsersList"] }); - toast.success("Member record removed successfully."); - }, - onError: () => { - toast.error("Failed to delete member."); + toast.success("Member profile log purged."); + setIsDetailsOpen(false); + setSelectedMember(null); }, }); const memberList = membersPayload?.data || []; - const totalItems = membersPayload?.total || 0; - const totalPages = Math.ceil(totalItems / 10) || 1; + const hasActiveFilters = Boolean(searchTerm || tierFilter || statusFilter); + const displayTotal = membersPayload?.total ?? 0; + + let displayActive: number | string = membersPayload?.globalActive ?? 0; + let displayExpired: number | string = membersPayload?.globalExpired ?? 0; + + if (statusFilter === "ACTIVE") displayExpired = "-"; + if (statusFilter === "EXPIRED") displayActive = "-"; + + const totalPages = Math.ceil(memberList.length / 10) || 1; const handleClearFilters = () => { - setSearchTerm(""); + setSearchTerm(""); setTierFilter(""); setStatusFilter(""); setCurrentPage(1); }; + const getInitials = (name: string) => (name ? name.charAt(0).toUpperCase() : "M"); + return ( -
- {/* Page Core Header Block */} -
+
+ + {/* ==================== ZONES A & B: HEADER & TRACKER ==================== */} +
-

- Members Management Desk -

-

- Click any record row to manage tier tracking, view info cards, or - extend membership renewals. -

-
- -
- - {/* Synchronized Filtering Control Pipeline Ribbon */} -
- {/* 🔍 Search Input Anchor Box */} -
- - - - { - setSearchTerm(e.target.value); - setCurrentPage(1); - }} - className="w-full pl-10 pr-10 py-2 bg-slate-50 border border-border-main text-text-main rounded-xl text-xs sm:text-sm font-medium outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" - /> - {searchTerm && ( - - )} +
+ Directory +
+

+ Members Registry +

- {/* Action Dropdowns Group Container */} -
- {/* Membership Tier Dropdown */} -
- - - +
+
+ + {displayTotal} + + + {hasActiveFilters ? "Matched" : "Total Members"}
- - {/* Account Status Filter Box */} -
- - - +
+
+ + {displayActive} + + + Active Plans
+
+
+ + {displayExpired} + + + Expired Plans + +
+
+
+ +
+ + {/* ==================== ZONE C: UTILITIES HEADER ==================== */} +
+
+ Members Ledger +
+ +
+
+ + { setSearchTerm(e.target.value); setCurrentPage(1); }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> + {searchTerm && ( + + )} +
- {/* Reset Filters Command */} + +
+ +
- {/* Master Content Ledger Grid Table */} - {isLoading ? ( -
- Syncing Library Membership Database... -
- ) : ( -
-
-
- - - - - - - - - - - - {memberList.length === 0 ? ( - - - - ) : ( - memberList.map((member) => ( - { - setSelectedMember(member); - setIsDetailsOpen(true); - }} - className="hover:bg-slate-50/80 transition-colors cursor-pointer group select-none" - > - - + + - {/* Pagination Navigation Footer Deck */} - {totalPages > 0 && ( -
- - Page {currentPage} / {totalPages}{" "} - | Total{" "} - {totalItems} Members - -
- - -
+
+ + + + ); + }) + )} + +
Member NameContact DetailsCurrent PlanPlan Expiry DateStatus
- No active matching subscriber accounts found on server - indexing. -
- {member.name} - -
- {member.email} + {/* ==================== ZONE D: GRID DISPLAY VIEW ==================== */} +
+
+ {isLoading ? ( +
+ Syncing active data ledger sequences... +
+ ) : ( +
+
+ + + + + + + + + + + + {memberList.length === 0 ? ( + + - - - { + setSelectedMember(member); + setIsDetailsOpen(true); + }} + className={`transition-all duration-150 cursor-pointer border-l-4 ${ + isCurrentSelection + ? 'bg-slate-50/80 border-l-4 border-l-blue-500' + : 'hover:bg-blue-50/40 border-l-4 border-l-transparent' }`} > - {member.isActive ? "Active" : "Expired"} - - - - )) - )} - -
MemberContact + + + {activeHeaderDropdown === "plan" && ( +
+ + {plans.map((p) => ( + + ))}
-
- {member.phoneNumber || "No Phone Contact"} + )} +
+ + + {activeHeaderDropdown === "status" && ( +
+ + +
+ )} +
+ No matching records currently indexed inside this filtered view. - - {member.membershipPlanName} - - - {member.expiryDate} - - + ) : ( + memberList.map((member) => { + const isCurrentSelection = selectedMember?.id === member.id; + return ( +
-
+
+
+
+ {getInitials(member.name)} +
+
+
{member.name}
+
Active Since: {member.activationDate || "N/A"}
+
+
+
+
{member.email}
+
{member.phoneNumber || "—"}
+
+
{member.membershipPlanName}
+
Standard Pipeline
+
+ + + {member.isActive ? "Active" : "Expired"} + +
- )} -
-
- )} - {/* Popup Form Modals Layers */} - setIsFormOpen(false)} - onSubmit={(vals) => saveMemberMutation.mutate(vals)} - users={users} - plans={plans} - editingMember={selectedMember} - /> + {/* Minimal Pagination Elements */} + {totalPages > 0 && ( +
+ Page {currentPage} of {totalPages} +
+ + +
+
+ )} +
+ )} +
+
+ {/* ==================== GLOBAL OVERLAY MODALS ==================== */} + + {/* 1. Member Information Details Context Modal */} setIsDetailsOpen(false)} member={selectedMember} plans={plans} + onClose={() => { setIsDetailsOpen(false); setSelectedMember(null); }} + onRenew={(planId) => renewMutation.mutate(planId)} onDelete={(id) => deleteMutation.mutate(id)} - onRenew={(planId) => - selectedMember && - renewMutation.mutate({ memberId: selectedMember.id, planId }) - } isRenewing={renewMutation.isPending} /> + + {/* 2. Member Form Modal (Handles Create and Updates) */} + { setIsFormOpen(false); setSelectedMember(null); }} + onSubmit={(vals) => saveMemberMutation.mutate(vals)} + users={users} + plans={plans} + editingMember={selectedMember} + />
); -}; +}; \ No newline at end of file diff --git a/client/src/features/membershipPlans/components/DeletePlanModal.tsx b/client/src/features/membershipPlans/components/DeletePlanModal.tsx index b3aed0d..157c7ea 100644 --- a/client/src/features/membershipPlans/components/DeletePlanModal.tsx +++ b/client/src/features/membershipPlans/components/DeletePlanModal.tsx @@ -50,39 +50,38 @@ export const DeletePlanModal = ({ return (
-
+
{/* Absolute Positioning Dismiss Controller */}
{/* Dynamic Critical Action Notification Core */}
-
+
-

+

Delete Membership Plan

-

+

Are you absolutely sure you want to permanently drop the configuration plan{" "} - "{planName}" from + "{planName}" from the target database cluster tables?

{/* Relational Constraints Alert Notice Callout Block */} -
- -

+

+ +

Relational Hazard: Members currently bound to this specific plan model id may experience query faults during system verification checkups. @@ -91,21 +90,19 @@ export const DeletePlanModal = ({

{/* Dynamic Action Trigger Tray */} -
+
diff --git a/client/src/features/membershipPlans/components/PlanDetailsModal.tsx b/client/src/features/membershipPlans/components/PlanDetailsModal.tsx index c32078c..9855b2e 100644 --- a/client/src/features/membershipPlans/components/PlanDetailsModal.tsx +++ b/client/src/features/membershipPlans/components/PlanDetailsModal.tsx @@ -1,20 +1,11 @@ import { useState } from "react"; -import type { MembershipPlan } from "../pages/ManagePlan"; -import { - X, - Layers, - Calendar, - BookOpen, - Settings, - Trash2, - IndianRupee, -} from "lucide-react"; +import type { ExtendedPlan } from "../pages/ManagePlan"; import { PlanModal } from "./PlanModal"; import { DeletePlanModal } from "./DeletePlanModal"; interface PlanDetailsModalProps { isOpen: boolean; - plan: MembershipPlan | null; + plan: ExtendedPlan | null; onClose: () => void; } @@ -31,117 +22,125 @@ export const PlanDetailsModal = ({ const displayId = plan.membership_plan_id?.slice(-4).toUpperCase() || "0000"; return ( - <> -
-
- {/* Header Strip Layer */} -
-
-

- Plan Details -

-

- Plan ID: PLAN-{displayId} -

-
- + <> + {/* High-contrast background overlay with clean backdrop filter */} +
+
+ + {/* Header Framework - Matching Reference Layout */} +
+
+

+ Plan Details +

+

+ Plan ID: PLAN-{displayId} +

+ +
- {/* Details Body Context Frame */} -
-
- + {/* Details Body Context Frame */} +
+
+ + {/* Plan Name Block */} +
+ Plan Name -
- - {plan.plan_name} -
+ + {plan.plan_name} +
-
-
- - Duration (Days) +
+ + {/* Properties Matrix */} +
+
+ + Duration Matrix - + {plan.duration_days} Days
- -
- - Max Limit + +
+ + Max Limit Allocation - + {plan.max_books_allowed} Books
-
- {/* High Impact Pricing Plate */} -
-
- - Plan Price +
+ + Plan Value Tier +
+ + ₹{plan.price} + +
- - ₹{plan.price} -
-
- {/* Core Configuration Control Base Actions */} -
- - + {/* Operations Layout Action Buttons matching reference spec perfectly */} +
+ + + +
+
- {showEditModal && ( - { - setShowEditModal(false); - onClose(); - }} - /> - )} + {/* Sub-Modal Layer Engines */} + {showEditModal && ( + { + setShowEditModal(false); + onClose(); + }} + /> + )} - {showDeleteConfirm && ( - setShowDeleteConfirm(false)} - onSuccessRedirect={() => { - setShowDeleteConfirm(false); - onClose(); - }} - /> - )} - - ); -}; + {showDeleteConfirm && ( + setShowDeleteConfirm(false)} + onSuccessRedirect={() => { + setShowDeleteConfirm(false); + onClose(); + }} + /> + )} + +);} \ No newline at end of file diff --git a/client/src/features/membershipPlans/components/PlanModal.tsx b/client/src/features/membershipPlans/components/PlanModal.tsx index 74c9a4d..9242eb6 100644 --- a/client/src/features/membershipPlans/components/PlanModal.tsx +++ b/client/src/features/membershipPlans/components/PlanModal.tsx @@ -1,15 +1,15 @@ import React, { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; -import type { MembershipPlan } from "../pages/ManagePlan"; +import type { ExtendedPlan } from "../pages/ManagePlan"; import { toast } from "sonner"; -import { X, Layers, IndianRupee, Calendar, BookOpen, Save } from "lucide-react"; +import { Layers, IndianRupee, Calendar, BookOpen, Save } from "lucide-react"; import axios from "axios"; interface PlanModalProps { isOpen: boolean; mode: "create" | "edit"; - plan: MembershipPlan | null; + plan: ExtendedPlan | null; onClose: () => void; } @@ -46,10 +46,18 @@ export const PlanModal = ({ isOpen, mode, plan, onClose }: PlanModalProps) => { }); } }, - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async () => { + // 1. Invalidate exact query match and refetch immediately + await queryClient.invalidateQueries({ queryKey: ["membershipPlansMasterFeed"], + exact: false, // Ensures partial query key combinations match }); + + // 2. Explicitly trigger a hard background refetch to force the UI Table to update + await queryClient.refetchQueries({ + queryKey: ["membershipPlansMasterFeed"], + }); + toast.success( mode === "create" ? "🎉 New Membership Plan Created Successfully" @@ -58,8 +66,7 @@ export const PlanModal = ({ isOpen, mode, plan, onClose }: PlanModalProps) => { onClose(); }, onError: (err: unknown) => { - let errorMessage = - "Execution error rejected parameter payload metrics map."; + let errorMessage = "Execution error rejected parameter payload metrics map."; if (axios.isAxiosError(err) && err.response?.data?.message) { errorMessage = err.response.data.message; } @@ -83,132 +90,149 @@ export const PlanModal = ({ isOpen, mode, plan, onClose }: PlanModalProps) => { }); }; - return ( -
-
- {/* Header Ribbon Layer - Clean Light Structured Banner */} -
-

- {mode === "create" ? "Add New Plan" : "Modify Plan Details"} -

- -
- -
- {/* Form Element: Plan Title Entry */} -
- -
- setPlanName(e.target.value)} - className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-border-main rounded-xl text-base font-semibold text-text-main placeholder:text-slate-400 outline-none focus:bg-card-bg focus:border-slate-400 transition-all" - /> - -
-
+ // Unique configuration token forces component tree identity remounting on form transitions + const uniqueModalInstanceKey = `${isOpen}-${mode}-${plan?.membership_plan_id || "new"}`; - {/* Form Element: Cost Calculation Matrix Input */} -
- -
- setPrice(e.target.value)} - className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-border-main rounded-xl text-base font-bold text-text-main placeholder:text-slate-400 outline-none focus:bg-card-bg focus:border-slate-400 transition-all" - /> - + return ( + <> + {/* High-contrast background overlay with clean backdrop filter */} +
+
+ + {/* Header Framework - Matching Reference Module Perfectly */} +
+
+

+ {mode === "create" ? "Add New Plan" : "Modify Plan Details"} +

+

+ Subscription Configuration +

+
- {/* Form Element Column Rows Layout Splits */} -
+ + + {/* Form Element: Plan Name */}
-
+ {/* Form Element: Price */}
-
-
- {/* Interactive Footer Controls Frame */} -
- - -
- + {/* Form Element Grid Splits */} +
+
+ +
+ setDurationDays(e.target.value)} + className="w-full pl-11 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-bold text-[#1A365D] placeholder-[#A0AEC0] outline-none focus:bg-white focus:border-[#2B6CB0] transition-all" + /> + +
+
+ +
+ +
+ setMaxBooks(e.target.value)} + className="w-full pl-11 pr-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-bold text-[#1A365D] placeholder-[#A0AEC0] outline-none focus:bg-white focus:border-[#2B6CB0] transition-all" + /> + +
+
+
+ + {/* Footer Actions: Matching Reference Button Themes */} +
+ + + +
+ +
-
+ ); -}; +}; \ No newline at end of file diff --git a/client/src/features/membershipPlans/pages/ManagePlan.tsx b/client/src/features/membershipPlans/pages/ManagePlan.tsx index 8d705ec..ef9cbb4 100644 --- a/client/src/features/membershipPlans/pages/ManagePlan.tsx +++ b/client/src/features/membershipPlans/pages/ManagePlan.tsx @@ -1,343 +1,329 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; - -// Component Modules import { PlanModal } from "../components/PlanModal"; import { PlanDetailsModal } from "../components/PlanDetailsModal"; - -// Lucide Icons +import type { MembershipPlan } from "../../../types/members"; +import { useAuthStore } from "../../../store/authStore"; import { - Search, - ChevronLeft, - ChevronRight, - ChevronDown, Plus, + Search, RotateCcw, - Award, - BookOpen, X, + Layers } from "lucide-react"; -export interface MembershipPlan { - membership_plan_id: string; - plan_name: string; - price: number; - duration_days: number; - max_books_allowed: number; - created_at: string; - updated_at: string; +export interface ExtendedPlan extends MembershipPlan { + infoText?: string; + isPlanActive?: boolean; + activeMembersCount: number; + inactiveMembersCount: number; + max_books_allowed:number; } export const ManagePlan = () => { - // Modals Core Management States - const [selectedPlan, setSelectedPlan] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); + const queryClient = useQueryClient(); - // Search & Metric Filter States - const [searchQuery, setSearchQuery] = useState(""); - const [durationFilter, setDurationFilter] = useState(""); - const [priceSortFilter, setPriceSortFilter] = useState(""); - - // Pagination Controls State + // Filter and pagination parameters const [currentPage, setCurrentPage] = useState(1); - const rowsPerPage = 10; + const [searchTerm, setSearchTerm] = useState(""); + + // Modal controllers + const [isFormOpen, setIsFormOpen] = useState(false); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(null); + + const token = useAuthStore((state) => state.token); - // Fetch Data Stream from Database - const { data: plansFeedPayload = [], isLoading } = useQuery( - { - queryKey: ["membershipPlansMasterFeed"], - queryFn: async () => { - const response = await axiosClient.get("/plan"); - return response.data?.data || response.data || []; - }, + // Core background querying pipeline matching data schema rows + const { data: plansPayload, isLoading } = useQuery<{ + total: number; + globalActiveMembers: number; + globalInactiveMembers: number; + data: ExtendedPlan[]; + }>({ + queryKey: ["membershipPlansFeed", token, currentPage, searchTerm], + queryFn: async () => { + const res = await axiosClient.get("/members/plans", { + params: { + page: currentPage, + limit: 10, + search: searchTerm || undefined, + }, + }); + + const rootData = res.data?.data || res.data; + const rawRecords = Array.isArray(rootData) ? rootData : (rootData?.data || []); + const totalCount = rootData?.meta?.total || rawRecords.length || 0; + + const transformed = rawRecords.map((dbRow: unknown): ExtendedPlan => { + const row = dbRow as Record; + + const activeCount = Number(row.active_members_count ?? row.active_members ?? row.activeMembers ?? 0); + const inactiveCount = Number(row.inactive_members_count ?? row.inactive_members ?? row.inactiveMembers ?? 0); + + return { + membership_plan_id: String(row.membership_plan_id || row.id || ""), + plan_name: String(row.plan_name || row.name || "Unnamed Tier Plan"), + price: Number(row.price || 0), + duration_days: Number(row.duration_days || row.duration || 0), + max_books_allowed: Number(row.max_books_allowed || row.maxBooks || 0), + infoText: String(row.description || row.infoText || "No plan description outlined."), + isPlanActive: row.is_active === true || row.membership_status === "ACTIVE" || row.status === "ACTIVE", + activeMembersCount: activeCount, + inactiveMembersCount: inactiveCount, + }; + }); + + const globalActiveMembersCount = rootData?.meta?.globalActiveMembers ?? transformed.reduce((acc: number, p: ExtendedPlan) => acc + p.activeMembersCount, 0); + const globalInactiveMembersCount = rootData?.meta?.globalInactiveMembers ?? transformed.reduce((acc: number, p: ExtendedPlan) => acc + p.inactiveMembersCount, 0); + + return { + total: totalCount, + globalActiveMembers: globalActiveMembersCount, + globalInactiveMembers: globalInactiveMembersCount, + data: transformed + }; }, - ); + enabled: !!token, + }); + + const rawPlanList = plansPayload?.data || []; + + const planList = rawPlanList.filter(plan => { + return plan.plan_name.toLowerCase().includes(searchTerm.toLowerCase()); + }); + + const hasActiveFilters = Boolean(searchTerm); + const displayTotal = planList.length; + + const totalPages = Math.ceil(planList.length / 10) || 1; - // Clear Filters Pipeline const handleClearFilters = () => { - setSearchQuery(""); - setDurationFilter(""); - setPriceSortFilter(""); + setSearchTerm(""); setCurrentPage(1); }; - // Multi-tier filtering context pipeline logic - const filteredPlans = plansFeedPayload - .filter((plan) => { - const term = searchQuery.toLowerCase().trim(); - const nameMatch = (plan?.plan_name || "").toLowerCase().includes(term); - const passesSearch = term === "" || nameMatch; - - let passesDuration = true; - if (durationFilter) { - passesDuration = plan.duration_days === Number(durationFilter); - } + const getInitials = (name: string) => (name ? name.charAt(0).toUpperCase() : "P"); - return passesSearch && passesDuration; - }) - .sort((a, b) => { - if (priceSortFilter === "low-to-high") return a.price - b.price; - if (priceSortFilter === "high-to-low") return b.price - a.price; - return 0; - }); - - // Inline dynamic pagination offsets - const totalItemsCount = filteredPlans.length; - const totalPagesCount = Math.ceil(totalItemsCount / rowsPerPage) || 1; - const paginatedRowsData = filteredPlans.slice( - (currentPage - 1) * rowsPerPage, - currentPage * rowsPerPage, - ); + const handleRowClick = (plan: ExtendedPlan) => { + setSelectedPlan(plan); + setIsDetailsOpen(true); + }; return ( -
- {/* Header Deck View */} -
+
+ + {/* ==================== ZONES A & B: HEADER & METRICS DASHBOARD ==================== */} +
-

- Membership Plans Management Desk -

-

- Configure available registration tiers, duration indexes, and - checkout limits. -

+
+ Setup +
+

+ Membsership Plans +

- + {/* Dynamic Metric Dashboard Tracker Section */} +
+
+ + {displayTotal} + + + {hasActiveFilters ? "Matched Plans" : "Total Plans"} + +
+
+
+ + {plansPayload?.globalActiveMembers ?? 0} + + + Active Plans + +
+
+
+ + {plansPayload?.globalInactiveMembers ?? 0} + + + Inactive Plans + +
+
- {/* Control Grid Pipeline Filters (Synchronized inline form alignment layout) */} -
- {/* Search Field Anchor Box */} -
- - - - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="w-full pl-10 pr-10 py-2 bg-slate-50 border border-border-main text-text-main rounded-xl text-xs sm:text-sm font-medium outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" - /> - {searchQuery && ( - - )} -
+
- {/* Action Select Dropdowns Group Container */} -
- {/* Duration Selector Block */} -
- - - - -
+ {/* ==================== ZONE C: UTILITIES ==================== */} +
+
+ Plans Matrix +
- {/* Pricing Sort Selector Block */} -
- - - - +
+
+ + { setSearchTerm(e.target.value); setCurrentPage(1); }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> + {searchTerm && ( + + )}
- {/* Reset Action Control Trigger */} + +
+ +
- {/* Central Interactive Data Core Grid Matrix */} - {isLoading ? ( -
- Streaming Active Membership Matrix Configuration Tiers... -
- ) : ( -
-
-
- - - - - - - - - - - - - {paginatedRowsData.length === 0 ? ( - - - - ) : ( - paginatedRowsData.map((plan) => { - const humanId = - plan.membership_plan_id?.slice(-4).toUpperCase() || - "0000"; - return ( - setSelectedPlan(plan)} - className="hover:bg-slate-50/80 transition-colors cursor-pointer group select-none" - > - - - - - - - - ); - }) - )} - -
Plan IDPlan NameDuration - Max Borrow Limit - PriceDate Created
- Operational Clear View. Zero matching membership target - layouts found. -
- PLAN-{humanId} - -
- - {plan.plan_name} -
-
- - {plan.duration_days} Days - - -
- - {plan.max_books_allowed} Vols -
-
- ₹{plan.price} - - {new Date(plan.created_at) - .toLocaleDateString("en-IN", { - day: "2-digit", - month: "short", - year: "numeric", - }) - .toUpperCase()} -
+ {/* ==================== ZONE D: GRID/TABLE FLEX DISPLAY ==================== */} +
+ + {/* Main Plans Table Column - Static Clean Width */} +
+ {isLoading ? ( +
+ Syncing active subscription structure sequences...
+ ) : ( +
+
+ + + + + + + + + + + {planList.length === 0 ? ( + + + + ) : ( + planList.map((plan) => { + return ( + handleRowClick(plan)} + className="transition-all duration-150 cursor-pointer hover:bg-blue-50/40" + > + + + - {/* Pagination Controls Footer block */} - {totalPagesCount > 0 && ( -
- - Page {currentPage} / {totalPagesCount}{" "} - | Total{" "} - {totalItemsCount} Plans - -
- - -
+
+ + + + ); + }) + )} + +
Plan TierDurationRate PricingActive Members
+ No customized plans matching this data parameters inside architecture view. +
+
+
+ {getInitials(plan.plan_name)} +
+
+ {plan.plan_name} +
+
+
+
{plan.duration_days} Days
+
+
+ ₹{plan.price.toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ {plan.activeMembersCount} +
- )} -
+ + {totalPages > 0 && ( +
+ Page {currentPage} of {totalPages} +
+ + +
+
+ )} +
+ )}
- )} +
- {/* Modals Infrastructure Overlay Layer Blocks */} - {showCreateModal && ( - setShowCreateModal(false)} - /> - )} + {/* Global Form Context Modal */} + { + setIsFormOpen(false); + // Invalidate cache immediately on close to catch newly created plans perfectly + queryClient.invalidateQueries({ queryKey: ["membershipPlansFeed"] }); + }} + /> - {selectedPlan && ( - setSelectedPlan(null)} - /> - )} + {/* Centered Details Layer Engine Overlay */} + { + setIsDetailsOpen(false); + setSelectedPlan(null); + queryClient.invalidateQueries({ queryKey: ["membershipPlansFeed"] }); + }} + />
); -}; +}; \ No newline at end of file diff --git a/client/src/features/returnedbooks/components/ConfirmationModal.tsx b/client/src/features/returnedbooks/components/ConfirmationModal.tsx index 4bf6da3..993bb80 100644 --- a/client/src/features/returnedbooks/components/ConfirmationModal.tsx +++ b/client/src/features/returnedbooks/components/ConfirmationModal.tsx @@ -33,69 +33,69 @@ const ConfirmationModal: React.FC = ({ info: "bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500", }; - return ( -
- {/* Backdrop overlay blur */} -
+ return ( +
+ {/* Backdrop overlay blur - System Core Reference Spec */} +
- {/* Modal Container */} -
-
- {/* Context Icon indicators matched down to severity matrices */} - {variant === "danger" && ( -
- -
- )} - {variant === "warning" && ( -
- -
- )} - {variant === "info" && ( -
- -
- )} + {/* Modal Container - Structurally scaled matching reference framework design */} +
+
+ {/* Context Icon indicators matched down to severity matrices */} + {variant === "danger" && ( +
+ +
+ )} + {variant === "warning" && ( +
+ +
+ )} + {variant === "info" && ( +
+ +
+ )} -
-

- {title} -

-
-

- {description} -

-
+
+

+ {title} +

+
+

+ {description} +

+
- {/* Action Control Grid Layout Footer Area */} -
- + {/* Action Control Layout Footer Area */} +
+ - -
+
- ); +
+); }; export default ConfirmationModal; diff --git a/client/src/features/returnedbooks/components/ReturnedDetailsModal.tsx b/client/src/features/returnedbooks/components/ReturnedDetailsModal.tsx index cda8f1e..7b7e5e9 100644 --- a/client/src/features/returnedbooks/components/ReturnedDetailsModal.tsx +++ b/client/src/features/returnedbooks/components/ReturnedDetailsModal.tsx @@ -1,5 +1,5 @@ import type { BookIssueRecord } from "../../../types/transactions"; -import { Mail, Phone, BookOpen, RefreshCw, Trash2, X } from "lucide-react"; +import { Mail, Phone, BookOpen, RefreshCw, Trash2 } from "lucide-react"; interface ReturnedDetailsModalProps { isOpen: boolean; @@ -24,112 +24,113 @@ export const ReturnedDetailsModal = ({ ? `ISSUE-${record.id.slice(-4).toUpperCase()}` : `ISSUE-${record.id}`; - return ( -
-
- {/* Header Block */} -
-
-

- Returned Book Details -

- - {formattedIssueId} - -
- + return ( +
+
+ {/* Header Framework - Matching Reference Module */} +
+
+

+ Returned Book Details +

+

+ ID: {formattedIssueId} +

+ +
-
+
+
{/* Member Card */} -
- +
+ Borrower Profile -
+
{record.memberName}
-
- {" "} +
+ {" "} {record.memberEmail || "No email provided"}
-
- {" "} +
+ {" "} {record.memberPhone || "No contact info"}
{/* Book Card */}
- + Book Details -
- +
+
{record.bookTitle}
-
+
Author:{" "} - + {record.bookAuthor || "Unknown author"}
{/* Core Timeline Grid */} -
+
- + Issued -
+
{record.borrowedDate}
- + Due Date -
{record.dueDate}
+
{record.dueDate}
Returned -
+
{record.returnedDate || "N/A"}
- {/* Operational Control Footers */} + {/* Operations Layout Action Buttons - Matching layout rules and theme */}
-
+
@@ -138,5 +139,6 @@ export const ReturnedDetailsModal = ({
- ); +
+); }; diff --git a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx index 326dfd5..a5abd6d 100644 --- a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx +++ b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx @@ -10,8 +10,6 @@ import { Search, Calendar, RotateCcw, - ChevronLeft, - ChevronRight, Trash2, } from "lucide-react"; @@ -209,52 +207,62 @@ export const ReturnedBooks = () => { clearAllHistoryMutation.isPending; return ( -
- {/* Control Layout Top-deck */} -
-
-

+
+ + {/* ==================== ZONE A & B: HEADER & GLOBAL CONTROLS ==================== */} +
+
+
Returned Books Management Desk -

-

- Review completed book returns, manage archiving, and track overdue - fine status ledgers. +

+

+ Returned Books Management Desk +

+

+ Review completed book returns, manage archiving, and track overdue fine status ledgers.

- {totalRecordsCount > 0 && ( - - )} + +
+ {totalRecordsCount > 0 && ( + + )} +
- {/* Interactive Operational Filters Section */} -
-
- - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="w-full pl-10 pr-4 py-2 bg-canvas-dominant border border-slate-light/10 rounded-xl text-xs sm:text-sm font-medium text-text-main focus:bg-card-bg focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary outline-hidden transition-all" - /> +
+ + {/* ==================== ZONE C: UTILITIES FILTER ROW ==================== */} +
+
+ Circulation Archives
-
-
- + {/* Search Bar aligned to the right side */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-text-main placeholder-slate-400 p-0 focus:ring-0 focus:outline-hidden" /> +
+ + {/* Date Picker Input */} +
+ { setDateFilter(e.target.value); setCurrentPage(1); }} - className="w-full pl-9 pr-3 py-2 bg-canvas-dominant border border-slate-light/10 rounded-xl text-xs font-bold uppercase tracking-wider text-slate-800 focus:bg-card-bg focus:ring-4 focus:ring-sage-primary/10 focus:border-sage-primary outline-hidden transition-all cursor-pointer" + className="bg-transparent border-0 outline-hidden w-full text-xs font-bold uppercase tracking-wider text-text-main p-0 focus:ring-0 focus:outline-hidden cursor-pointer" />
+ + {/* Reset Action Button */}
- {isLoading ? ( -
- Accessing historical records archives... -
- ) : ( -
- {/* INLINE NATURAL LAYOUT TABLE CONTAINER */} -
-
- - - - - - - - - {/* */} - - - - {paginatedRecords.length === 0 ? ( - - +
Issued Book TitleBorrower NameIssued OnTarget DueReturned OnFines Ledger
- No historical book returns matching the selected filter - criteria. + {/* ==================== ZONE D: GRID DISPLAY TABLE ==================== */} + +
+ {isLoading ? ( +
+ Accessing historical records archives... +
+ ) : ( +
+
+
+ + + + + + + + + + + + + {paginatedRecords.length === 0 ? ( + + + + ) : ( + paginatedRecords.map((record) => { + return ( + { + setSelectedRecord(record); + setIsDetailsOpen(true); + }} + className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" + > + + + + + + + + + - ) : ( - paginatedRecords.map((record) => { - // const isOverdueDrop = record.returnedDate && record.dueDate && record.returnedDate > record.dueDate; - - return ( - { - setSelectedRecord(record); - setIsDetailsOpen(true); - }} - className="hover:bg-canvas-dominant/60 transition-colors cursor-pointer group select-none" - > - - - - - - {/* */} - - ); - }) - )} - -
+ Issued Book Title + + Borrower Name + + Issued On + + Target Due + + Returned On +
+ No historical book returns matching the selected filter criteria. +
+
+ {record.bookTitle} +
+ + By {record.bookAuthor} + +
+
+ {record.memberName} +
+
+
+ {record.borrowedDate} +
+
+
+ {record.dueDate} +
+
+ + {record.returnedDate} +
-
{record.bookTitle}
- - By {record.bookAuthor} - -
- {record.memberName} - - {record.borrowedDate} - - {record.dueDate} - - {record.returnedDate} - - - {isOverdueDrop ? "⚠️ Fine Accrued" : "✓ Settled"} - -
-
+ ); + }) + )} +
+
- {totalPages > 0 && ( -
- - Page {currentPage} / {totalPages}{" "} - | Total{" "} - {totalRecordsCount} Books - -
- - -
-
- )} + {totalPages > 0 && ( +
+ + Page{" "} + + {currentPage} + {" "} + of{" "} + + {totalPages} + + | + Total{" "} + + {totalRecordsCount} + {" "} + Books + + +
+ + + +
-
- )} + )} +
+
+ )} +
+ {/* History Details Lookup Modal */} { />
); -}; +}; \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css index 88f28d9..a2f0a78 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -13,8 +13,8 @@ --color-utility-crimson: #C0392B; /* Critical validation / blocks / alerts */ /* 📐 2. Font Pair Configurations */ - --font-sans: "Inter", system-ui, sans-serif; /* Inter for Primary UI & Headings */ - --font-data: "Roboto", sans-serif; /* Roboto for numbers, dates, lists */ + --font-sans: "Inter", system-ui, sans-serif; + --font-data: "Inter", system-ui, sans-serif; /* 🌗 3. Dynamic Dark-Mode Semantic Mapping Tokens */ --color-page-bg: var(--page-bg); diff --git a/client/src/layouts/DashboardLayout.tsx b/client/src/layouts/DashboardLayout.tsx index 32fe32e..8121ab1 100644 --- a/client/src/layouts/DashboardLayout.tsx +++ b/client/src/layouts/DashboardLayout.tsx @@ -1,8 +1,9 @@ -import { Outlet, NavLink, useNavigate } from "react-router-dom"; +import { useState, useEffect, useRef } from "react"; +import { Outlet, NavLink, useNavigate, useLocation } from "react-router-dom"; import { useAuthStore } from "../store/authStore"; -import { motion } from "framer-motion"; +import { motion, AnimatePresence } from "framer-motion"; -// Lucide Icons with balanced stroke weight for high legibility +// Lucide Icons with balanced stroke weight for clean, institutional clarity import { LayoutDashboard, Users, @@ -15,28 +16,46 @@ import { LogOut, Library, User, + Menu, } from "lucide-react"; export const DashboardLayout = () => { const { user, logout } = useAuthStore(); const navigate = useNavigate(); + const location = useLocation(); + + // State engine managing navigation flyout and dashboard scroll adjustments + const [menuOpen, setMenuOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + + const mainScrollContainerRef = useRef(null); - // Initialize theme state safely by matching persistent localStorage cache indices - // const [darkMode, setDarkMode] = useState( - // () => localStorage.getItem("theme") === "dark" - // ); - - // Synchronize layout class strings on theme changes - // useEffect(() => { - // const rootWindowElement = window.document.documentElement; - // if (darkMode) { - // rootWindowElement.classList.add("dark"); - // localStorage.setItem("theme", "dark"); - // } else { - // rootWindowElement.classList.remove("dark"); - // localStorage.setItem("theme", "light"); - // } - // }, [darkMode]); + // Layout Rule: Transparent header states apply only at zero-scroll offset on the primary dashboard + const isDashboardPage = location.pathname === "/dashboard" || location.pathname === "/"; + + // Watch scroll container progression to toggle header canvas states dynamically + useEffect(() => { + const scrollEl = mainScrollContainerRef.current; + if (!scrollEl) return; + + const handleScrollUpdate = () => { + if (scrollEl.scrollTop > 20) { + setIsScrolled(true); + } else { + setIsScrolled(false); + } + }; + + scrollEl.addEventListener("scroll", handleScrollUpdate); + return () => scrollEl.removeEventListener("scroll", handleScrollUpdate); + }, [location.pathname]); + + // Handle immediate viewport layout reset on route change + useEffect(() => { + if (mainScrollContainerRef.current) { + mainScrollContainerRef.current.scrollTop = 0; + } + }, [location.pathname]); const handleSignOut = () => { logout(); @@ -44,122 +63,152 @@ export const DashboardLayout = () => { }; const navItems = [ - { name: "Dashboard", path: "/dashboard", icon: LayoutDashboard, color: "text-blue-600" }, - { name: "Manage Members", path: "/members", icon: Users, color: "text-teal-600" }, - { name: "Manage Plans", path: "/plans", icon: CreditCard, color: "text-indigo-600" }, - { name: "Manage Books", path: "/books", icon: BookOpen, color: "text-amber-700" }, - { name: "Manage Categories", path: "/categories", icon: Tags, color: "text-purple-600" }, - { name: "Borrow & Return Desk", path: "/transactions", icon: RefreshCw, color: "text-emerald-600" }, - { name: "Returned Books", path: "/returnedbooks", icon: BookCheck, color: "text-sky-600" }, - { name: "Fines & Payments", path: "/fines", icon: Receipt, color: "text-orange-600" }, + { name: "Dashboard", path: "/dashboard", icon: LayoutDashboard }, + { name: "Manage Members", path: "/members", icon: Users }, + { name: "Manage Plans", path: "/plans", icon: CreditCard }, + { name: "Manage Books", path: "/books", icon: BookOpen }, + { name: "Manage Categories", path: "/categories", icon: Tags }, + { name: "Borrow & Return Desk", path: "/transactions", icon: RefreshCw }, + { name: "Returned Books", path: "/returnedbooks", icon: BookCheck }, + { name: "Fines & Payments", path: "/fines", icon: Receipt }, ]; + // Configure operational header styling based on page context and scroll threshold + const getHeaderStyles = () => { + if (isDashboardPage) { + return isScrolled + ? "fixed top-0 left-0 right-0 bg-[#1A365D] border-b border-[#E2E8F0]/10 shadow-sm text-white" + : "absolute top-0 left-0 right-0 bg-transparent border-b border-transparent text-white"; + } + return "relative bg-[#1A365D] border-b border-[#E2E8F0]/10 shadow-sm text-white"; + }; + return ( -
+
- {/* Sidebar Navigation - Warm Archival Minimalist Layout */} - - {/* Main Structural Area */} -
- - {/* Header Frame - High Visibility Layout */} -
-
-

Librarian Dashboard

-

Real-time catalog and circulation auditing pipeline

+ {/* User Identity Matrix */} +
+
+

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

- -
-
-

- ROLE: {user?.role || "LIBRARIAN"} -

-
- - {/* User Profile Avatar Frame */} -
- -
- - {/* Dark/Light Theme Button Switcher */} - {/* */} - - + +
+
-
- - {/* Content View Injection Portal */} -
- - - -
-
+
+ + + {/* Slideout Shell Menu System */} + + {menuOpen && ( + <> + {/* Structural Backdrop Dimmer Mask */} + setMenuOpen(false)} + className="absolute inset-0 bg-[#1A365D]/30 backdrop-blur-xs z-40 cursor-pointer" + /> + + {/* Main Full-Height Left Drawer Panel */} + +
+ + {/* Drawer Branding Header Section */} +
+
+ +
+ System Directory +
+ + {/* Navigation Routing Links: Anchored on Navy with clean Accent Gold active lines */} + +
+ + {/* Action Area Drawer Footer with crisp institutional action styling */} +
+ +
+
+ + )} +
+ + {/* Main Document / Workspace Framework Interface Content Slot */} +
+ + + +
+
); }; \ No newline at end of file diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index de7d61e..c2fbfd9 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -38,13 +38,13 @@ export const Dashboard = () => { // High-Visibility Light State Loading Screen if (isLoading || !token) { return ( -
+
{/* Crisp slate structural spinner */} -
+
-

+

Booting Control Center Intelligence...

@@ -54,11 +54,11 @@ export const Dashboard = () => { // High-Visibility Error Alert Board if (isError) { return ( -
-

+
+

Operational Sync Failure

-

+

Unable to pull central analytical datasets from backend database registers. Please reload the dashboard engine view.

@@ -80,47 +80,59 @@ export const Dashboard = () => { totalOutstandingFines: summary.totalOutstandingFines ?? 0, }; + // ... (imports remain the same) + return ( - /* Comfortable, light-weight archival grid background wrapper */ -
+
+ {/* Level 1: Core System KPI Indicators */}
- {/* Level 2: Realtime Alerts (Critical Deficits, Dead Stocks, Analytics) */} -
- - - + {/* Level 2: Realtime Alerts */} +
+
+ + + +
- {/* Level 3: Financial Risks & Auditing Simulation Controls */} -
- - -
+
+ {/* Level 3: Fine Velocity & Amnesty Simulator */} +
+
+ + +
+
- {/* Level 4: Traffic Indexes & Distribution Return Timelines */} -
- - -
+ {/* Level 4: Traffic Indexes & Distribution Return Timelines */} +
+
+ + +
+
- {/* Level 5: Media Density Map & Reader Engagement Logs */} -
-
- + {/* Level 5: Media Density Map & Reader Engagement Logs */} +
+
+
+ +
+ +
-
); -}; +}; \ No newline at end of file diff --git a/client/src/types/members.ts b/client/src/types/members.ts index 82d20d7..9d64e6b 100644 --- a/client/src/types/members.ts +++ b/client/src/types/members.ts @@ -10,6 +10,7 @@ export interface MembershipPlan { plan_name: string; duration_days: number; price: number; + max_books_allowed: number; } export interface LibraryMember { diff --git a/server/src/modules/books/book.controller.ts b/server/src/modules/books/book.controller.ts index 917e911..af9714e 100644 --- a/server/src/modules/books/book.controller.ts +++ b/server/src/modules/books/book.controller.ts @@ -25,12 +25,16 @@ export const getBooksController = asyncHandler( const limit = Number(req.query.limit) || 10; const search = req.query.search ? String(req.query.search).trim() : undefined; const category_id = req.query.category_id ? String(req.query.category_id).trim() : undefined; + // 🌟 FIX: Extract the language query parameter securely from the request URL string + const language = req.query.language ? String(req.query.language).trim() : undefined; + // 🌟 FIX: Pass language as the 5th parameter into your service layer const result = await bookService.getBooks( page, limit, search, - category_id + category_id, + language ); sendResponse(res, { diff --git a/server/src/modules/books/book.repository.ts b/server/src/modules/books/book.repository.ts index 549c7b2..3e02770 100644 --- a/server/src/modules/books/book.repository.ts +++ b/server/src/modules/books/book.repository.ts @@ -18,13 +18,7 @@ class BookRepository { } as CreationAttributes); } - /** - * 💡 Handles clean pagination limits, offsets, mixed title/author searches, - * and isolated category ID relational filtering. - */ - - -async getBooks( + async getBooks( page: number, limit: number, search?: string, @@ -49,35 +43,65 @@ async getBooks( whereClause.category_id = category_id; } - // 🌟 FIXED: Explicit strict lowercase validation to safeguard string matches + // Explicit strict matching safeguarding string casing discrepancies if (language) { whereClause.language = { - [Op.iLike]: language.trim() // Protects against hidden spaces or casing mismatches (e.g., "hindi" vs "Hindi") + [Op.iLike]: language.trim() }; } - // 2. Fire structured finder query payload - return Book.findAndCountAll({ - where: whereClause, - include: [ - { - model: Category, - as: "category", - attributes: [ - ["category_id", "id"], - ["category_name", "name"] - ], - required: false // Keeps books visible even if their category isn't loaded - }, - ], - limit, - offset, - order: [["created_at", "DESC"]], - // 🌟 CRITICAL FOR PAGINATION WITH INNER JOINS: - // Prevents duplicate row counts and keeps where clauses locked to the parent table! - distinct: true, - }); -} + // 2. Concurrently fire the paginated rows query and the global meta aggregations + // Using Promise.all keeps database round-trip costs optimally minimized. + const [paginatedResult, metadataAggregation] = await Promise.all([ + // Main Paginated Data Engine Fetch + Book.findAndCountAll({ + where: whereClause, + include: [ + { + model: Category, + as: "category", + attributes: [ + ["category_id", "id"], + ["category_name", "name"] + ], + required: false + }, + ], + limit, + offset, + order: [["created_at", "DESC"]], + distinct: true, + }), + + // Global Aggregate Metrics Engine + // Note: We deliberately pass the exact same whereClause parameters here so that + // the header indicators accurately reflect the currently filtered subset. + Book.findAll({ + where: whereClause, + attributes: [ + [fn("SUM", col("total_copies")), "globalTotalCopies"], + [fn("SUM", col("available_copies")), "globalAvailableCopies"] + ], + raw: true + }) + ]); + + // 3. Extract the computed sums safely out of the array response raw rows + const metricsRow = metadataAggregation[0] as unknown as Record; + const globalTotalCopies = Number(metricsRow?.globalTotalCopies || 0); + const globalAvailableCopies = Number(metricsRow?.globalAvailableCopies || 0); + + // 4. Return custom formatted metadata response payload structural block + return { + rows: paginatedResult.rows, + count: paginatedResult.count, + meta: { + globalTotalCopies, + globalAvailableCopies + } + }; + } + async getBookById(book_id: string) { return Book.findByPk(book_id, { include: [ diff --git a/server/src/modules/books/book.service.ts b/server/src/modules/books/book.service.ts index 90db658..fc152e8 100644 --- a/server/src/modules/books/book.service.ts +++ b/server/src/modules/books/book.service.ts @@ -20,9 +20,11 @@ class BookService { return bookRepository.createBook(completePayload); } - async getBooks(page: number, limit: number, search?: string, category_id?: string) { - return bookRepository.getBooks(page, limit, search, category_id); - } + // 🌟 FIX: Add language?: string to your service layer method parameters +async getBooks(page: number, limit: number, search?: string, category_id?: string, language?: string) { + // 🌟 FIX: Forward the language parameter directly down to the repository method execution context + return bookRepository.getBooks(page, limit, search, category_id, language); +} async getBookById(book_id: string) { const book = await bookRepository.getBookById(book_id); diff --git a/server/src/modules/books/book.spec.ts b/server/src/modules/books/book.spec.ts index d75178a..0f24d36 100644 --- a/server/src/modules/books/book.spec.ts +++ b/server/src/modules/books/book.spec.ts @@ -1,371 +1,371 @@ -import { jest } from "@jest/globals"; - -jest.unstable_mockModule("./book.repository.js", () => ({ - default: { - createBook: jest.fn(), - getBooks: jest.fn(), - getBookById: jest.fn(), - updateBook: jest.fn(), - deleteBook: jest.fn(), - searchBooks: jest.fn(), - getCategories: jest.fn(), - }, -})); - -jest.unstable_mockModule( - "../../database/models/Category.js", - () => ({ - default: { - findByPk: jest.fn(), - }, - }) -); - -const { default: bookService } = - await import("./book.service.js"); - -const { default: bookRepository } = - await import("./book.repository.js"); - -const { default: Category } = - await import( - "../../database/models/Category.js" - ); - -const mockBookRepository = - bookRepository as any; - -const mockCategory = - Category as any; - -describe("Book Service Unit Tests", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe("createBook", () => { - const payload = { - book_name: "Clean Code", - book_author: "Robert Martin", - category_id: "cat-1", - total_copies: 10, - }; - - it("should create book successfully", async () => { - mockCategory.findByPk.mockResolvedValue({ - category_id: "cat-1", - }); - - mockBookRepository.createBook.mockResolvedValue( - { - book_id: "book-1", - ...payload, - available_copies: 10, - } - ); - - const result = - await bookService.createBook(payload); - - expect( - mockCategory.findByPk - ).toHaveBeenCalledWith( - payload.category_id - ); - - expect( - mockBookRepository.createBook - ).toHaveBeenCalledWith({ - ...payload, - available_copies: 10, - }); - - expect(result.book_id).toBe("book-1"); - }); - - it("should throw when category does not exist", async () => { - mockCategory.findByPk.mockResolvedValue( - null - ); - - await expect( - bookService.createBook(payload) - ).rejects.toMatchObject({ - message: "Category not found", - statusCode: 404, - }); - - expect( - mockBookRepository.createBook - ).not.toHaveBeenCalled(); - }); - - it("should set available copies equal to total copies", async () => { - mockCategory.findByPk.mockResolvedValue({ - category_id: "cat-1", - }); - - mockBookRepository.createBook.mockResolvedValue( - {} - ); - - await bookService.createBook(payload); - - expect( - mockBookRepository.createBook - ).toHaveBeenCalledWith({ - ...payload, - available_copies: 10, - }); - }); - }); - - describe("getBookById", () => { - it("should return book", async () => { - const book = { - book_id: "book-1", - }; - - mockBookRepository.getBookById.mockResolvedValue( - book - ); - - const result = - await bookService.getBookById( - "book-1" - ); - - expect(result).toEqual(book); - }); - - it("should throw when book not found", async () => { - mockBookRepository.getBookById.mockResolvedValue( - null - ); - - await expect( - bookService.getBookById( - "book-1" - ) - ).rejects.toMatchObject({ - message: "Book not found", - statusCode: 404, - }); - }); - }); - - describe("updateBook", () => { - it("should update normal fields", async () => { - const existingBook = { - total_copies: 10, - available_copies: 6, - }; - - mockBookRepository.getBookById.mockResolvedValue( - existingBook - ); - - mockBookRepository.updateBook.mockResolvedValue( - { - book_id: "book-1", - } - ); - - await bookService.updateBook( - "book-1", - { - book_name: "Updated Book", - } - ); - - expect( - mockBookRepository.updateBook - ).toHaveBeenCalledWith( - "book-1", - { - book_name: "Updated Book", - } - ); - }); - - it("should throw when book does not exist", async () => { - mockBookRepository.getBookById.mockResolvedValue( - null - ); - - await expect( - bookService.updateBook( - "book-1", - {} - ) - ).rejects.toMatchObject({ - message: "Book not found", - statusCode: 404, - }); - }); - - it("should recalculate available copies when increasing total copies", async () => { - mockBookRepository.getBookById.mockResolvedValue( - { - total_copies: 10, - available_copies: 6, - } - ); - - await bookService.updateBook( - "book-1", - { - total_copies: 15, - } - ); - - expect( - mockBookRepository.updateBook - ).toHaveBeenCalledWith( - "book-1", - { - total_copies: 15, - available_copies: 11, - } - ); - }); - - it("should recalculate available copies when decreasing total copies", async () => { - mockBookRepository.getBookById.mockResolvedValue( - { - total_copies: 10, - available_copies: 6, - } - ); - - await bookService.updateBook( - "book-1", - { - total_copies: 8, - } - ); - - expect( - mockBookRepository.updateBook - ).toHaveBeenCalledWith( - "book-1", - { - total_copies: 8, - available_copies: 4, - } - ); - }); - - it("should throw when total copies goes below active loans", async () => { - mockBookRepository.getBookById.mockResolvedValue( - { - total_copies: 10, - available_copies: 6, - } - ); - - await expect( - bookService.updateBook( - "book-1", - { - total_copies: 3, - } - ) - ).rejects.toMatchObject({ - statusCode: 400, - }); - - expect( - mockBookRepository.updateBook - ).not.toHaveBeenCalled(); - }); - }); - - describe("deleteBook", () => { - it("should delete book successfully", async () => { - mockBookRepository.getBookById.mockResolvedValue( - { - book_id: "book-1", - } - ); - - mockBookRepository.deleteBook.mockResolvedValue( - 1 - ); - - await bookService.deleteBook( - "book-1" - ); - - expect( - mockBookRepository.deleteBook - ).toHaveBeenCalledWith( - "book-1" - ); - }); - - it("should throw when book not found", async () => { - mockBookRepository.getBookById.mockResolvedValue( - null - ); - - await expect( - bookService.deleteBook( - "book-1" - ) - ).rejects.toMatchObject({ - message: "Book not found", - statusCode: 404, - }); - }); - }); - - describe("searchBooks", () => { - it("should return empty array for empty search", async () => { - const result = - await bookService.searchBooks( - "" - ); - - expect(result).toEqual([]); - }); - - it("should trim search token before repository call", async () => { - mockBookRepository.searchBooks.mockResolvedValue( - [] - ); - - await bookService.searchBooks( - " clean code " - ); - - expect( - mockBookRepository.searchBooks - ).toHaveBeenCalledWith( - "clean code" - ); - }); - }); - - describe("getCategories", () => { - it("should return categories", async () => { - const categories = [ - { - id: "cat-1", - name: "Programming", - }, - ]; - - mockBookRepository.getCategories.mockResolvedValue( - categories - ); - - const result = - await bookService.getCategories(); - - expect(result).toEqual( - categories - ); - }); - }); -}); \ No newline at end of file +// import { jest } from "@jest/globals"; + +// jest.unstable_mockModule("./book.repository.js", () => ({ +// default: { +// createBook: jest.fn(), +// getBooks: jest.fn(), +// getBookById: jest.fn(), +// updateBook: jest.fn(), +// deleteBook: jest.fn(), +// searchBooks: jest.fn(), +// getCategories: jest.fn(), +// }, +// })); + +// jest.unstable_mockModule( +// "../../database/models/Category.js", +// () => ({ +// default: { +// findByPk: jest.fn(), +// }, +// }) +// ); + +// const { default: bookService } = +// await import("./book.service.js"); + +// const { default: bookRepository } = +// await import("./book.repository.js"); + +// const { default: Category } = +// await import( +// "../../database/models/Category.js" +// ); + +// const mockBookRepository = +// bookRepository as any; + +// const mockCategory = +// Category as any; + +// describe("Book Service Unit Tests", () => { +// beforeEach(() => { +// jest.clearAllMocks(); +// }); + +// describe("createBook", () => { +// const payload = { +// book_name: "Clean Code", +// book_author: "Robert Martin", +// category_id: "cat-1", +// total_copies: 10, +// }; + +// it("should create book successfully", async () => { +// mockCategory.findByPk.mockResolvedValue({ +// category_id: "cat-1", +// }); + +// mockBookRepository.createBook.mockResolvedValue( +// { +// book_id: "book-1", +// ...payload, +// available_copies: 10, +// } +// ); + +// const result = +// await bookService.createBook(payload); + +// expect( +// mockCategory.findByPk +// ).toHaveBeenCalledWith( +// payload.category_id +// ); + +// expect( +// mockBookRepository.createBook +// ).toHaveBeenCalledWith({ +// ...payload, +// available_copies: 10, +// }); + +// expect(result.book_id).toBe("book-1"); +// }); + +// it("should throw when category does not exist", async () => { +// mockCategory.findByPk.mockResolvedValue( +// null +// ); + +// await expect( +// bookService.createBook(payload) +// ).rejects.toMatchObject({ +// message: "Category not found", +// statusCode: 404, +// }); + +// expect( +// mockBookRepository.createBook +// ).not.toHaveBeenCalled(); +// }); + +// it("should set available copies equal to total copies", async () => { +// mockCategory.findByPk.mockResolvedValue({ +// category_id: "cat-1", +// }); + +// mockBookRepository.createBook.mockResolvedValue( +// {} +// ); + +// await bookService.createBook(payload); + +// expect( +// mockBookRepository.createBook +// ).toHaveBeenCalledWith({ +// ...payload, +// available_copies: 10, +// }); +// }); +// }); + +// describe("getBookById", () => { +// it("should return book", async () => { +// const book = { +// book_id: "book-1", +// }; + +// mockBookRepository.getBookById.mockResolvedValue( +// book +// ); + +// const result = +// await bookService.getBookById( +// "book-1" +// ); + +// expect(result).toEqual(book); +// }); + +// it("should throw when book not found", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// null +// ); + +// await expect( +// bookService.getBookById( +// "book-1" +// ) +// ).rejects.toMatchObject({ +// message: "Book not found", +// statusCode: 404, +// }); +// }); +// }); + +// describe("updateBook", () => { +// it("should update normal fields", async () => { +// const existingBook = { +// total_copies: 10, +// available_copies: 6, +// }; + +// mockBookRepository.getBookById.mockResolvedValue( +// existingBook +// ); + +// mockBookRepository.updateBook.mockResolvedValue( +// { +// book_id: "book-1", +// } +// ); + +// await bookService.updateBook( +// "book-1", +// { +// book_name: "Updated Book", +// } +// ); + +// expect( +// mockBookRepository.updateBook +// ).toHaveBeenCalledWith( +// "book-1", +// { +// book_name: "Updated Book", +// } +// ); +// }); + +// it("should throw when book does not exist", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// null +// ); + +// await expect( +// bookService.updateBook( +// "book-1", +// {} +// ) +// ).rejects.toMatchObject({ +// message: "Book not found", +// statusCode: 404, +// }); +// }); + +// it("should recalculate available copies when increasing total copies", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// { +// total_copies: 10, +// available_copies: 6, +// } +// ); + +// await bookService.updateBook( +// "book-1", +// { +// total_copies: 15, +// } +// ); + +// expect( +// mockBookRepository.updateBook +// ).toHaveBeenCalledWith( +// "book-1", +// { +// total_copies: 15, +// available_copies: 11, +// } +// ); +// }); + +// it("should recalculate available copies when decreasing total copies", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// { +// total_copies: 10, +// available_copies: 6, +// } +// ); + +// await bookService.updateBook( +// "book-1", +// { +// total_copies: 8, +// } +// ); + +// expect( +// mockBookRepository.updateBook +// ).toHaveBeenCalledWith( +// "book-1", +// { +// total_copies: 8, +// available_copies: 4, +// } +// ); +// }); + +// it("should throw when total copies goes below active loans", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// { +// total_copies: 10, +// available_copies: 6, +// } +// ); + +// await expect( +// bookService.updateBook( +// "book-1", +// { +// total_copies: 3, +// } +// ) +// ).rejects.toMatchObject({ +// statusCode: 400, +// }); + +// expect( +// mockBookRepository.updateBook +// ).not.toHaveBeenCalled(); +// }); +// }); + +// describe("deleteBook", () => { +// it("should delete book successfully", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// { +// book_id: "book-1", +// } +// ); + +// mockBookRepository.deleteBook.mockResolvedValue( +// 1 +// ); + +// await bookService.deleteBook( +// "book-1" +// ); + +// expect( +// mockBookRepository.deleteBook +// ).toHaveBeenCalledWith( +// "book-1" +// ); +// }); + +// it("should throw when book not found", async () => { +// mockBookRepository.getBookById.mockResolvedValue( +// null +// ); + +// await expect( +// bookService.deleteBook( +// "book-1" +// ) +// ).rejects.toMatchObject({ +// message: "Book not found", +// statusCode: 404, +// }); +// }); +// }); + +// describe("searchBooks", () => { +// it("should return empty array for empty search", async () => { +// const result = +// await bookService.searchBooks( +// "" +// ); + +// expect(result).toEqual([]); +// }); + +// it("should trim search token before repository call", async () => { +// mockBookRepository.searchBooks.mockResolvedValue( +// [] +// ); + +// await bookService.searchBooks( +// " clean code " +// ); + +// expect( +// mockBookRepository.searchBooks +// ).toHaveBeenCalledWith( +// "clean code" +// ); +// }); +// }); + +// describe("getCategories", () => { +// it("should return categories", async () => { +// const categories = [ +// { +// id: "cat-1", +// name: "Programming", +// }, +// ]; + +// mockBookRepository.getCategories.mockResolvedValue( +// categories +// ); + +// const result = +// await bookService.getCategories(); + +// expect(result).toEqual( +// categories +// ); +// }); +// }); +// }); \ No newline at end of file diff --git a/server/src/modules/books/book.validation.ts b/server/src/modules/books/book.validation.ts index 52e8ae7..2c432c7 100644 --- a/server/src/modules/books/book.validation.ts +++ b/server/src/modules/books/book.validation.ts @@ -12,12 +12,12 @@ export const createBookSchema = z.object({ category_id: z.uuid("Invalid classification identifier formatting"), - // 💡 FIX: Coerce strings to actual numbers to handle native form values smoothly total_copies: z.coerce .number() .min(1, "Total copies must be at least 1"), - langauge: z.string().min(2, "langauge name atleast contain 2 charcaters") + // 🌟 FIXED TYPO HERE (langauge -> language) + language: z.string().min(2, "Language name must contain at least 2 characters") }), }); @@ -28,7 +28,8 @@ export const updateBookSchema = z.object({ category_id: z.uuid().optional(), total_copies: z.coerce.number().optional(), available_copies: z.coerce.number().optional(), - langauge: z.string().optional() + // 🌟 FIXED TYPO HERE (langauge -> language) + language: z.string().optional() }), }); @@ -41,13 +42,13 @@ export const searchBooksQueryValidation = z.object({ }), }); -// 🌟 NEW: Clean Validation Framework for your main ledger list dashboard parameters! -// Validates parameters for: GET /api/v1/books?page=1&limit=10&search=Rich&category_id=... +// 🌟 FIXED: Added language validation rules to your Ledger List validation definitions! export const getBooksQueryValidation = z.object({ query: z.object({ page: z.coerce.number().min(1).default(1), limit: z.coerce.number().min(1).default(10), search: z.string().optional(), category_id: z.string().uuid().optional().or(z.literal("")), + language: z.string().optional(), // 🌟 ALLOW LANGUAGE FILTERS THROUGH QUERY VALIDATION }), }); \ No newline at end of file diff --git a/server/src/modules/categories/categories.repository.ts b/server/src/modules/categories/categories.repository.ts index 77b8ea3..e51a7fc 100644 --- a/server/src/modules/categories/categories.repository.ts +++ b/server/src/modules/categories/categories.repository.ts @@ -10,61 +10,67 @@ class CategoryRepository { * and aggregation function matrices. */ async getCategoriesWithMetrics( - page: number, - limit: number, - search?: string, - bookSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH", - borrowSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH" - ) { - const offset = (page - 1) * limit; + page: number, + limit: number, + search?: string, + bookSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH", + borrowSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH" +) { + const offset = (page - 1) * limit; - // Define the dynamic ordering rules based on aggregate metrics aliases - let orderClause: any[] = [["category_name", "ASC"]]; // Default alphabetical sort - if (bookSort && bookSort !== "NONE") { - orderClause = [[fn("COUNT", fn("DISTINCT", col("books.book_id"))), bookSort === "HIGH_TO_LOW" ? "DESC" : "ASC"]]; - } else if (borrowSort && borrowSort !== "NONE") { - orderClause = [[fn("COUNT", col("books->issues.issue_id")), borrowSort === "HIGH_TO_LOW" ? "DESC" : "ASC"]]; - } - - return Category.findAndCountAll({ - attributes: [ - "category_id", - "category_name", - "created_at", - "updated_at", - [fn("COUNT", fn("DISTINCT", col("books.book_id"))), "booksCount"], - [fn("COUNT", col("books->issues.issue_id")), "lendingCount"] - ], - where: { - ...(search && { - category_name: { [Op.iLike]: `%${search.trim()}%` } - }) - }, - include: [ - { - model: Book, - as: "books", - attributes: [], - required: false, - include: [ - { - model: Issue, - as: "issues", - attributes: [], - required: false - } - ] - } - ], - group: ["Category.category_id"], - order: orderClause, - limit, - offset, - subQuery: false, // Prevents Sequelize from placing limit/offset inside a nested subquery wrapper which messes up joins - raw: true - }); + // Define the dynamic ordering rules based on aggregate metrics aliases + let orderClause: any[] = [["category_name", "ASC"]]; // Default alphabetical sort + if (bookSort && bookSort !== "NONE") { + orderClause = [[fn("COUNT", fn("DISTINCT", col("books.book_id"))), bookSort === "HIGH_TO_LOW" ? "DESC" : "ASC"]]; + } else if (borrowSort && borrowSort !== "NONE") { + orderClause = [[fn("COUNT", col("books->issues.issue_id")), borrowSort === "HIGH_TO_LOW" ? "DESC" : "ASC"]]; } + return Category.findAndCountAll({ + attributes: [ + "category_id", + "category_name", + "created_at", + "updated_at", + // 1. Total distinct book titles/entries under this category + [fn("COUNT", fn("DISTINCT", col("books.book_id"))), "booksCount"], + + // 2. Total physical stock copies combined across all books in this category + [fn("COALESCE", fn("SUM", col("books.total_copies")), 0), "totalCopies"], + + // 3. Total historical borrow/lending transactions + [fn("COUNT", col("books->issues.issue_id")), "lendingCount"] + ], + where: { + ...(search && { + category_name: { [Op.iLike]: `%${search.trim()}%` } + }) + }, + include: [ + { + model: Book, + as: "books", + attributes: [], + required: false, + include: [ + { + model: Issue, + as: "issues", + attributes: [], + required: false + } + ] + } + ], + group: ["Category.category_id"], + order: orderClause, + limit, + offset, + subQuery: false, // Prevents Sequelize from placing limit/offset inside a nested subquery wrapper which messes up joins + raw: true + }); +} + /** * Look up an existing profile match by name (case-insensitive) */ diff --git a/server/src/modules/categories/categories.service.ts b/server/src/modules/categories/categories.service.ts index 4be4812..945cd01 100644 --- a/server/src/modules/categories/categories.service.ts +++ b/server/src/modules/categories/categories.service.ts @@ -5,32 +5,42 @@ class CategoriesService { /** * Retrieves all categories along with aggregated counts for books and loans */ - async getAllCategoriesWithMetrics( - page: number, - limit: number, - search?: string, - bookSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH", - borrowSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH" - ) { - const result = await categoryRepository.getCategoriesWithMetrics( - page, - limit, - search, - bookSort, - borrowSort - ); +async getAllCategoriesWithMetrics( + page: number, + limit: number, + search?: string, + bookSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH", + borrowSort?: "NONE" | "HIGH_TO_LOW" | "LOW_TO_HIGH" +) { + const result = await categoryRepository.getCategoriesWithMetrics( + page, + limit, + search, + bookSort, + borrowSort + ); - // Because Sequelize 'group by' is active, findAndCountAll returns "count" - // as an array of grouped row lengths. We extract the accurate data length here: - const totalCount = Array.isArray(result.count) ? result.count.length : result.count; + // Because Sequelize 'group by' is active, findAndCountAll returns "count" + // as an array of grouped row lengths. We extract the accurate data length here: + const totalCount = Array.isArray(result.count) ? result.count.length : result.count; + // Clean data records by sanitizing database string aggregates into pure numbers + const sanitizedRows = result.rows.map((row: any) => { return { - rows: result.rows, - totalCount, - totalPages: Math.ceil(totalCount / limit), - currentPage: page + ...row, + booksCount: row.booksCount ? parseInt(row.booksCount, 10) : 0, + totalCopies: row.totalCopies ? parseInt(row.totalCopies, 10) : 0, + lendingCount: row.lendingCount ? parseInt(row.lendingCount, 10) : 0, }; - } + }); + + return { + rows: sanitizedRows, + totalCount, + totalPages: Math.ceil(totalCount / limit), + currentPage: page + }; +} /** * Business Logic: Creates a new category slot after verifying name uniqueness diff --git a/server/src/modules/dashboard/dashboard.repository.ts b/server/src/modules/dashboard/dashboard.repository.ts index b6c7959..3ccefb6 100644 --- a/server/src/modules/dashboard/dashboard.repository.ts +++ b/server/src/modules/dashboard/dashboard.repository.ts @@ -11,11 +11,12 @@ class DashboardRepository { /** * Fetches the flat overview counters for the core metrics endpoint */ + /** + * Fetches the flat overview counters for the core metrics endpoint + */ async getOverview() { const [ - totalCopies, // 🌟 FIXED: Swapped out findOne for explicit column sum - availableCopies, // 🌟 FIXED: Swapped out findOne for explicit column sum - totalBooks, + bookMetricsResult, // 🌟 FIXED: Consolidated parent-level calculations totalMembers, activeMembers, expiredMembers, @@ -24,9 +25,15 @@ class DashboardRepository { overdueCount, fineAggregationResult, ] = await Promise.all([ - Book.sum("total_copies"), - Book.sum("available_copies"), - Book.count(), + // Aggregating directly via findAll to guarantee scope alignment + Book.findAll({ + attributes: [ + [fn("COUNT", col("book_id")), "totalBooks"], + [fn("COALESCE", fn("SUM", col("total_copies")), 0), "totalCopies"], + [fn("COALESCE", fn("SUM", col("available_copies")), 0), "availableCopies"] + ], + raw: true + }), Member.count(), Member.count({ where: { membership_status: "ACTIVE" } }), Member.count({ where: { membership_status: "EXPIRED" } }), @@ -45,12 +52,16 @@ class DashboardRepository { }), ]); + const bookMetrics = bookMetricsResult?.[0] as any; + const totalBooks = Number(bookMetrics?.totalBooks || 0); + const totalCopies = Number(bookMetrics?.totalCopies || 0); + const availableCopies = Number(bookMetrics?.availableCopies || 0); const unpaidFines = fineAggregationResult ? Number((fineAggregationResult as any).total_unpaid) : 0; return { - totalBooks, - totalCopies: Number(totalCopies || 0), - availableCopies: Number(availableCopies || 0), + totalBooks, + totalCopies, + availableCopies, totalMembers, activeMembers, expiredMembers, @@ -61,24 +72,28 @@ class DashboardRepository { }; } + async getDashboardSummaryData() { const today = new Date(); // ========================================================================= - // 1. CONCURRENT ROOT COUNT AGGREGATIONS (FIXED & ALIGNED) + // 1. CONCURRENT ROOT COUNT AGGREGATIONS (FIXED & FULLY ALIGNED) // ========================================================================= const [ - totalCopiesCount, // 🌟 FIXED: Direct mathematical sum - availableBooksCount, // 🌟 FIXED: Direct mathematical sum - totalBooksCount, + bookMetricsResult, // 🌟 FIXED: Consolidated to match exact database schema lines activeMembersCount, issuedBooksCount, fineAggregationResult, recoveredFinesResult ] = await Promise.all([ - Book.sum("total_copies"), - Book.sum("available_copies"), - Book.count(), + Book.findAll({ + attributes: [ + [fn("COUNT", col("book_id")), "totalBooks"], + [fn("COALESCE", fn("SUM", col("total_copies")), 0), "totalCopies"], + [fn("COALESCE", fn("SUM", col("available_copies")), 0), "availableBooks"] + ], + raw: true + }), Member.count({ where: { membership_status: "ACTIVE" } }), Issue.count({ where: { returned_date: null } }), Fine.findOne({ @@ -93,6 +108,11 @@ class DashboardRepository { }) ]); + const bookMetrics = bookMetricsResult?.[0] as any; + const totalBooksCount = Number(bookMetrics?.totalBooks || 0); + const totalCopiesCount = Number(bookMetrics?.totalCopies || 0); + const availableBooksCount = Number(bookMetrics?.availableBooks || 0); + const totalFinesAgg = fineAggregationResult ? Number((fineAggregationResult as any).total_unpaid) : 0; const collectedFinesAgg = recoveredFinesResult ? Number((recoveredFinesResult as any).total_recovered) : 0; @@ -297,13 +317,13 @@ class DashboardRepository { }; // ========================================================================= - // 4. UNIFIED CONSOLIDATED DATA OUTPUT HANDOFF (FIXED MAP) + // 4. UNIFIED CONSOLIDATED DATA OUTPUT HANDOFF // ========================================================================= return { summary: { totalBooks: totalBooksCount, - totalCopies: Number(totalCopiesCount || 0), // 🌟 FIXED - availableBooks: Number(availableBooksCount || 0),// 🌟 FIXED + totalCopies: totalCopiesCount, + availableBooks: availableBooksCount, activeMembers: activeMembersCount, overdueCount, overduePercentage, diff --git a/server/src/modules/members/member.controller.ts b/server/src/modules/members/member.controller.ts index 64e8e44..f54176a 100644 --- a/server/src/modules/members/member.controller.ts +++ b/server/src/modules/members/member.controller.ts @@ -10,6 +10,7 @@ import { updateMemberService, searchMembersByNameService, getEligibleUsersForMemberService, // 💡 Imported the missing service engine link + getAllPlansWithMetricsService, // 💡 ADDED: Importing the plans metric service link } from "./member.service.js"; // 💡 FEATURE UPDATED: Refactored to leverage clean service-to-repo patterns with explicit READER filter constraints @@ -126,4 +127,20 @@ export const searchMembersByNameController = asyncHandler(async (req: Request, r message: "Library member directory queried successfully matching criteria.", data: detailedMatches, }); +}); + +// ========================================================= +// NEW: GET ALL PLANS METRICS CONTROLLER +// ========================================================= +export const getAllPlansWithMetricsController = asyncHandler(async (req: Request, res: Response) => { + const searchTerm = req.query.search as string; + + const result = await getAllPlansWithMetricsService(searchTerm); + + sendResponse(res, { + success: true, + statusCode: 200, + message: "Membership plans analytics dashboard matrices structural feed compiled.", + data: result, // Contains data: plans[], meta: { total, globalActiveMembers, globalInactiveMembers } + }); }); \ No newline at end of file diff --git a/server/src/modules/members/member.repository.ts b/server/src/modules/members/member.repository.ts index 33db667..552e148 100644 --- a/server/src/modules/members/member.repository.ts +++ b/server/src/modules/members/member.repository.ts @@ -2,97 +2,155 @@ import Member from "../../database/models/Member.js"; import User from "../../database/models/User.js"; import MembershipPlan from "../../database/models/MembershipPlan.js"; import { CreateMemberPayload, UpdateMemberPayload } from "./member.types.js"; -import { Op, WhereOptions } from "sequelize"; +import { Op, WhereOptions, Sequelize } from "sequelize"; import Issue from "../../database/models/Issue.js"; + export const createMemberRepository = async (payload: CreateMemberPayload) => { // 💡 Safe database row instantiation with clean explicit formatting return await Member.create(payload as any); }; -export const getAllMembersRepository = async (query: Record) => { +export const getAllMembersRepository = async ( + query: Record +) => { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const offset = (page - 1) * limit; - // 1. Bulk update expired records cleanly using a standardized string date token - const todayString = new Date().toISOString().split('T')[0]; - + const todayString = new Date().toISOString().split("T")[0]; + await Member.update( { membership_status: "EXPIRED" }, { where: { - expiry_date: { [Op.lt]: todayString }, - membership_status: { [Op.ne]: "EXPIRED" } - } + expiry_date: { [Op.lt]: todayString }, + membership_status: { [Op.ne]: "EXPIRED" }, + }, } ); - // 2. Build explicit Member level filter properties + // ========================================================= + // TABLE FILTERS (status affects table rows) + // ========================================================= + const memberWhereClause: WhereOptions = {}; + if (query.status) { memberWhereClause.membership_status = query.status; } else if (query.membership_status) { memberWhereClause.membership_status = query.membership_status; } - // 3. Build dynamic configuration structures for association tables const userInclude: Record = { model: User, as: "user", - attributes: ["uuid", "name", "gmail", "phone_number"] + attributes: ["uuid", "name", "gmail", "phone_number"], }; if (query.search) { userInclude.where = { - name: { [Op.iLike]: `%${query.search}%` } + name: { + [Op.iLike]: `%${query.search}%`, + }, }; } const planInclude: Record = { model: MembershipPlan, as: "membership_plan", - attributes: ["membership_plan_id", "plan_name", "price"] + attributes: ["membership_plan_id", "plan_name", "price"], }; if (query.plan) { planInclude.where = { - plan_name: query.plan + plan_name: query.plan, }; } - // 4. Fire final structured find query + // ========================================================= + // TABLE DATA QUERY + // ========================================================= + const result = await Member.findAndCountAll({ where: memberWhereClause, limit, offset, include: [userInclude, planInclude], order: [["created_at", "DESC"]], - distinct: true + distinct: true, }); - // 5. 💡 NEW: Map over the records to fix the ID mismatch and inject the custom short ID badge const formattedRows = result.rows.map((memberInstance: any) => { - // Convert Sequelize model instance to plain JSON object so we can modify properties const member = memberInstance.toJSON(); - - // Explicitly grab the true member table ID key (adjust column name to match your DB schema, e.g., 'id' or 'member_id') - const trueMemberId = member.id || member.member_id || ""; - - // Extract last 4 alphanumeric characters cleanly from the string - const cleanUuidString = String(trueMemberId).replace(/-/g, ""); // strip hyphens if needed - const shortToken = cleanUuidString.slice(-4).toUpperCase(); - + + const trueMemberId = + member.id || member.member_id || ""; + + const cleanUuidString = String(trueMemberId).replace( + /-/g, + "" + ); + + const shortToken = cleanUuidString + .slice(-4) + .toUpperCase(); + return { ...member, - id: trueMemberId, // ✅ Forces frontend key to explicitly point to Member Table Primary Key - displayId: `MEMBER-${shortToken || "0000"}`, // ✨ Custom badge ready for your dashboard grid (e.g., MEMBER-2240) + id: trueMemberId, + displayId: `MEMBER-${shortToken || "0000"}`, }; }); + // ========================================================= + // DASHBOARD COUNTS + // ONLY PLAN FILTER SHOULD AFFECT THESE + // STATUS FILTER MUST BE IGNORED + // ========================================================= + + let dashboardPlanWhere: any = {}; + + if (query.plan) { + const selectedPlan = await MembershipPlan.findOne({ + where: { + plan_name: query.plan, + }, + attributes: ["membership_plan_id"], + }); + + if (selectedPlan) { + dashboardPlanWhere.membership_plan_id = + selectedPlan.get("membership_plan_id"); + } + } + + const [dashboardTotal, totalActive, totalExpired] = + await Promise.all([ + Member.count({ + where: dashboardPlanWhere, + }), + + Member.count({ + where: { + ...dashboardPlanWhere, + membership_status: "ACTIVE", + }, + }), + + Member.count({ + where: { + ...dashboardPlanWhere, + membership_status: "EXPIRED", + }, + }), + ]); + return { - count: result.count, - rows: formattedRows + count: dashboardTotal, // dashboard total + totalActive, + totalExpired, + rows: formattedRows, // table rows }; }; @@ -231,4 +289,65 @@ export const searchMembersByNameRepository = async (searchToken: string) => { ); return detailedResults; -}; \ No newline at end of file +}; + +export const getAllPlansWithMetrics = async (searchTerm?: string) => { + const whereClause: any = {}; + + // 💡 FIXED: Stripped the non-existent 'description' search parameter to avoid SQL crashes + if (searchTerm) { + whereClause.plan_name = { [Op.iLike]: `%${searchTerm}%` }; + } + + // 1. Fetch raw plans along with active/inactive counters per plan + const plans = await MembershipPlan.findAll({ + where: whereClause, + attributes: { + include: [ + // 💡 FIXED: Pointed to explicit 'members' lowercase table and 'membership_status' column parameters + [ + Sequelize.literal(`( + SELECT COUNT(*)::int + FROM "members" AS m + WHERE m.membership_plan_id = "MembershipPlan".membership_plan_id + AND m.membership_status = 'ACTIVE' + )`), + 'active_members_count' + ], + // 💡 FIXED: Inactive members are those whose membership_status evaluates to 'EXPIRED' + [ + Sequelize.literal(`( + SELECT COUNT(*)::int + FROM "members" AS m + WHERE m.membership_plan_id = "MembershipPlan".membership_plan_id + AND m.membership_status = 'EXPIRED' + )`), + 'inactive_members_count' + ] + ] + }, + // 💡 FIXED: Target the proper snake_case column 'created_at' matching model options configuration + order: [["created_at", "DESC"]] + }); + + // 2. Fetch Global aggregated numbers across the complete platform + // Using explicit Member.count builds clean optimized queries automatically and avoids raw SQL failures! + const [globalActiveMembers, globalInactiveMembers] = await Promise.all([ + Member.count({ + where: { + membership_status: "ACTIVE" + } + }), + Member.count({ + where: { + membership_status: "EXPIRED" + } + }) + ]); + + return { + plans, + globalActiveMembers, + globalInactiveMembers + }; +}; diff --git a/server/src/modules/members/member.routes.ts b/server/src/modules/members/member.routes.ts index 61deeac..02cb171 100644 --- a/server/src/modules/members/member.routes.ts +++ b/server/src/modules/members/member.routes.ts @@ -236,14 +236,10 @@ * description: Membership plans fetched successfully */ - -import { Router } from "express"; - +import { Router, Request, Response } from "express"; import auth from "../../middlewares/auth.js"; import validate from "../../middlewares/validate.js"; import MembershipPlan from "../../database/models/MembershipPlan.js"; -import asyncHandler from "../../utils/asyncHandler.js"; -import { Request, Response } from "express"; import { createMemberController, @@ -252,14 +248,16 @@ import { getMemberByIdController, updateMemberController, getAvailableUsersController, - searchMembersByNameController // ✨ NEW: Import the search controller + searchMembersByNameController, + getAllPlansWithMetricsController // 💡 ADDED: Imported the plans dashboard controller } from "./member.controller.js"; import { createMemberValidation, updateMemberValidation, getMembersQueryValidation, - searchMembersQueryValidation + searchMembersQueryValidation, + getPlansQueryValidation // 💡 ADDED: Imported the query parameter validator schema } from "./member.validation.js"; const router = Router(); @@ -271,14 +269,44 @@ router.get( getAvailableUsersController ); -// 2. Lookup active membership plan structures -router.get("/plans", auth, asyncHandler(async (req: Request, res: Response) => { - const plans = await MembershipPlan.findAll(); - res.status(200).json({ - success: true, - data: plans - }); -})); +/** + * @route GET /api/plans/dropdown + * @desc Fetch all available membership plans for UI dropdown selections + * @access Public / Protected (Depending on your middleware) + */router.get("/dropdown", async (req: Request, res: Response): Promise => { + try { + // 1. Fetching from database + const plans = await MembershipPlan.findAll({ + order: [["plan_name", "ASC"]], + }); + + // 2. Sending successful response + return res.status(200).json({ + success: true, + message: "Membership plans retrieved successfully.", + data: plans, + }); + } catch (error) { + // 🌟 Look at your terminal console running the server to see the exact database error! + console.error("====== DROPDOWN CRASH LOG ======", error); + + return res.status(500).json({ + success: false, + message: "Internal server error while syncing data ledger sequences.", + error: error instanceof Error ? error.message : String(error), + }); + } +}); + +// 2. Lookup active membership plan structures with dashboard aggregation metrics +router.get( + "/plans", + auth, + validate(getPlansQueryValidation), // 💡 ADDED: Validates query parameters securely + getAllPlansWithMetricsController // 💡 ADDED: Routes payload logic down the architecture pipeline +); + + // ⭐ NEW: Search directory matching frontend type-ahead bars // 🛡️ CRITICAL PLACEMENT: Located ABOVE /:id to prevent UUID type-casting crashes! @@ -327,4 +355,6 @@ router.delete( deleteMemberController ); + + export default router; \ No newline at end of file diff --git a/server/src/modules/members/member.service.ts b/server/src/modules/members/member.service.ts index dea966e..67370f7 100644 --- a/server/src/modules/members/member.service.ts +++ b/server/src/modules/members/member.service.ts @@ -8,6 +8,7 @@ import { updateMemberRepository, searchMembersByNameRepository, getEligibleUsersForMemberRepository, + getAllPlansWithMetrics, // 💡 ADDED: Importing the new metric repository function } from "./member.repository.js"; import { CreateMemberPayload, @@ -57,6 +58,8 @@ export const getAllMembersService = async (query: MemberQuery) => { return { meta: { total: members.count, + globalActive: members.totalActive, + globalExpired: members.totalExpired, page: Number(query.page) || 1, limit: Number(query.limit) || 10, }, @@ -113,6 +116,7 @@ export const updateMemberService = async (memberId: string, payload: UpdateMembe return updatedMember; }; + export const deleteMemberService = async (memberId: string) => { const deletedMember = await deleteMemberRepository(memberId); if (!deletedMember) { @@ -126,4 +130,20 @@ export const searchMembersByNameService = async (searchToken: string) => { return []; } return await searchMembersByNameRepository(searchToken.trim()); +}; + +// ========================================================= +// NEW: PLANS WITH MEMBERS MEMBERSHIP METRICS SERVICE +// ========================================================= +export const getAllPlansWithMetricsService = async (searchTerm?: string) => { + const { plans, globalActiveMembers, globalInactiveMembers } = await getAllPlansWithMetrics(searchTerm); + + return { + meta: { + total: plans.length, + globalActiveMembers, + globalInactiveMembers + }, + data: plans + }; }; \ No newline at end of file diff --git a/server/src/modules/members/member.types.ts b/server/src/modules/members/member.types.ts index e10d76d..daab15f 100644 --- a/server/src/modules/members/member.types.ts +++ b/server/src/modules/members/member.types.ts @@ -44,4 +44,27 @@ export interface SearchMemberResult { message: string; isBlocked: boolean; }; +} + +// ========================================================= +// NEW: PLANS WITH METRICS DASHBOARD PAYLOAD CONTRACTS +// ========================================================= +export interface PlanWithMetrics { + membership_plan_id: string; + plan_name: string; + price: number; + duration_days: number; + description?: string; + active_members_count: number; + inactive_members_count: number; + [key: string]: any; // Accommodates general Sequelize instance properties +} + +export interface PlansDashboardResponse { + meta: { + total: number; + globalActiveMembers: number; + globalInactiveMembers: number; + }; + data: PlanWithMetrics[]; } \ No newline at end of file diff --git a/server/src/modules/members/member.validation.ts b/server/src/modules/members/member.validation.ts index c0d2204..9523ab5 100644 --- a/server/src/modules/members/member.validation.ts +++ b/server/src/modules/members/member.validation.ts @@ -59,4 +59,15 @@ export const searchMembersQueryValidation = z.object({ .string({ error: "Search lookup token 'q' is a required query parameter." }) .min(1, { message: "Search criteria must contain at least 1 character." }), }), +}); + +// ========================================================= +// NEW: GET ALL PLANS METRICS QUERY PARAMETERS VALIDATION +// ========================================================= +export const getPlansQueryValidation = z.object({ + query: z.object({ + page: z.preprocess((val) => String(val), z.string()).optional(), + limit: z.preprocess((val) => String(val), z.string()).optional(), + search: z.string().optional(), + }), }); \ No newline at end of file From 2e17063b91a0770086bc38c167e8b925883b0bfa Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 16 Jun 2026 12:31:02 +0530 Subject: [PATCH 04/11] fix: Make all test cases passed successfully --- server/src/modules/books/book.spec.ts | 743 +++++++++--------- .../src/modules/categories/categories.spec.ts | 55 +- server/src/modules/fines/fine.spec.ts | 206 +++-- server/src/modules/members/member.spec.ts | 2 + server/src/modules/plans/plans.spec.ts | 30 +- server/src/tests/books/book.test.ts | 3 +- 6 files changed, 604 insertions(+), 435 deletions(-) diff --git a/server/src/modules/books/book.spec.ts b/server/src/modules/books/book.spec.ts index 0f24d36..5f8a257 100644 --- a/server/src/modules/books/book.spec.ts +++ b/server/src/modules/books/book.spec.ts @@ -1,371 +1,372 @@ -// import { jest } from "@jest/globals"; - -// jest.unstable_mockModule("./book.repository.js", () => ({ -// default: { -// createBook: jest.fn(), -// getBooks: jest.fn(), -// getBookById: jest.fn(), -// updateBook: jest.fn(), -// deleteBook: jest.fn(), -// searchBooks: jest.fn(), -// getCategories: jest.fn(), -// }, -// })); - -// jest.unstable_mockModule( -// "../../database/models/Category.js", -// () => ({ -// default: { -// findByPk: jest.fn(), -// }, -// }) -// ); - -// const { default: bookService } = -// await import("./book.service.js"); - -// const { default: bookRepository } = -// await import("./book.repository.js"); - -// const { default: Category } = -// await import( -// "../../database/models/Category.js" -// ); - -// const mockBookRepository = -// bookRepository as any; - -// const mockCategory = -// Category as any; - -// describe("Book Service Unit Tests", () => { -// beforeEach(() => { -// jest.clearAllMocks(); -// }); - -// describe("createBook", () => { -// const payload = { -// book_name: "Clean Code", -// book_author: "Robert Martin", -// category_id: "cat-1", -// total_copies: 10, -// }; - -// it("should create book successfully", async () => { -// mockCategory.findByPk.mockResolvedValue({ -// category_id: "cat-1", -// }); - -// mockBookRepository.createBook.mockResolvedValue( -// { -// book_id: "book-1", -// ...payload, -// available_copies: 10, -// } -// ); - -// const result = -// await bookService.createBook(payload); - -// expect( -// mockCategory.findByPk -// ).toHaveBeenCalledWith( -// payload.category_id -// ); - -// expect( -// mockBookRepository.createBook -// ).toHaveBeenCalledWith({ -// ...payload, -// available_copies: 10, -// }); - -// expect(result.book_id).toBe("book-1"); -// }); - -// it("should throw when category does not exist", async () => { -// mockCategory.findByPk.mockResolvedValue( -// null -// ); - -// await expect( -// bookService.createBook(payload) -// ).rejects.toMatchObject({ -// message: "Category not found", -// statusCode: 404, -// }); - -// expect( -// mockBookRepository.createBook -// ).not.toHaveBeenCalled(); -// }); - -// it("should set available copies equal to total copies", async () => { -// mockCategory.findByPk.mockResolvedValue({ -// category_id: "cat-1", -// }); - -// mockBookRepository.createBook.mockResolvedValue( -// {} -// ); - -// await bookService.createBook(payload); - -// expect( -// mockBookRepository.createBook -// ).toHaveBeenCalledWith({ -// ...payload, -// available_copies: 10, -// }); -// }); -// }); - -// describe("getBookById", () => { -// it("should return book", async () => { -// const book = { -// book_id: "book-1", -// }; - -// mockBookRepository.getBookById.mockResolvedValue( -// book -// ); - -// const result = -// await bookService.getBookById( -// "book-1" -// ); - -// expect(result).toEqual(book); -// }); - -// it("should throw when book not found", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// null -// ); - -// await expect( -// bookService.getBookById( -// "book-1" -// ) -// ).rejects.toMatchObject({ -// message: "Book not found", -// statusCode: 404, -// }); -// }); -// }); - -// describe("updateBook", () => { -// it("should update normal fields", async () => { -// const existingBook = { -// total_copies: 10, -// available_copies: 6, -// }; - -// mockBookRepository.getBookById.mockResolvedValue( -// existingBook -// ); - -// mockBookRepository.updateBook.mockResolvedValue( -// { -// book_id: "book-1", -// } -// ); - -// await bookService.updateBook( -// "book-1", -// { -// book_name: "Updated Book", -// } -// ); - -// expect( -// mockBookRepository.updateBook -// ).toHaveBeenCalledWith( -// "book-1", -// { -// book_name: "Updated Book", -// } -// ); -// }); - -// it("should throw when book does not exist", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// null -// ); - -// await expect( -// bookService.updateBook( -// "book-1", -// {} -// ) -// ).rejects.toMatchObject({ -// message: "Book not found", -// statusCode: 404, -// }); -// }); - -// it("should recalculate available copies when increasing total copies", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// { -// total_copies: 10, -// available_copies: 6, -// } -// ); - -// await bookService.updateBook( -// "book-1", -// { -// total_copies: 15, -// } -// ); - -// expect( -// mockBookRepository.updateBook -// ).toHaveBeenCalledWith( -// "book-1", -// { -// total_copies: 15, -// available_copies: 11, -// } -// ); -// }); - -// it("should recalculate available copies when decreasing total copies", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// { -// total_copies: 10, -// available_copies: 6, -// } -// ); - -// await bookService.updateBook( -// "book-1", -// { -// total_copies: 8, -// } -// ); - -// expect( -// mockBookRepository.updateBook -// ).toHaveBeenCalledWith( -// "book-1", -// { -// total_copies: 8, -// available_copies: 4, -// } -// ); -// }); - -// it("should throw when total copies goes below active loans", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// { -// total_copies: 10, -// available_copies: 6, -// } -// ); - -// await expect( -// bookService.updateBook( -// "book-1", -// { -// total_copies: 3, -// } -// ) -// ).rejects.toMatchObject({ -// statusCode: 400, -// }); - -// expect( -// mockBookRepository.updateBook -// ).not.toHaveBeenCalled(); -// }); -// }); - -// describe("deleteBook", () => { -// it("should delete book successfully", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// { -// book_id: "book-1", -// } -// ); - -// mockBookRepository.deleteBook.mockResolvedValue( -// 1 -// ); - -// await bookService.deleteBook( -// "book-1" -// ); - -// expect( -// mockBookRepository.deleteBook -// ).toHaveBeenCalledWith( -// "book-1" -// ); -// }); - -// it("should throw when book not found", async () => { -// mockBookRepository.getBookById.mockResolvedValue( -// null -// ); - -// await expect( -// bookService.deleteBook( -// "book-1" -// ) -// ).rejects.toMatchObject({ -// message: "Book not found", -// statusCode: 404, -// }); -// }); -// }); - -// describe("searchBooks", () => { -// it("should return empty array for empty search", async () => { -// const result = -// await bookService.searchBooks( -// "" -// ); - -// expect(result).toEqual([]); -// }); - -// it("should trim search token before repository call", async () => { -// mockBookRepository.searchBooks.mockResolvedValue( -// [] -// ); - -// await bookService.searchBooks( -// " clean code " -// ); - -// expect( -// mockBookRepository.searchBooks -// ).toHaveBeenCalledWith( -// "clean code" -// ); -// }); -// }); - -// describe("getCategories", () => { -// it("should return categories", async () => { -// const categories = [ -// { -// id: "cat-1", -// name: "Programming", -// }, -// ]; - -// mockBookRepository.getCategories.mockResolvedValue( -// categories -// ); - -// const result = -// await bookService.getCategories(); - -// expect(result).toEqual( -// categories -// ); -// }); -// }); -// }); \ No newline at end of file +import { jest } from "@jest/globals"; + +jest.unstable_mockModule("./book.repository.js", () => ({ + default: { + createBook: jest.fn(), + getBooks: jest.fn(), + getBookById: jest.fn(), + updateBook: jest.fn(), + deleteBook: jest.fn(), + searchBooks: jest.fn(), + getCategories: jest.fn(), + }, +})); + +jest.unstable_mockModule( + "../../database/models/Category.js", + () => ({ + default: { + findByPk: jest.fn(), + }, + }) +); + +const { default: bookService } = + await import("./book.service.js"); + +const { default: bookRepository } = + await import("./book.repository.js"); + +const { default: Category } = + await import( + "../../database/models/Category.js" + ); + +const mockBookRepository = + bookRepository as any; + +const mockCategory = + Category as any; + +describe("Book Service Unit Tests", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("createBook", () => { + const payload = { + book_name: "Clean Code", + book_author: "Robert Martin", + category_id: "cat-1", + total_copies: 10, + language: "English", + }; + + it("should create book successfully", async () => { + mockCategory.findByPk.mockResolvedValue({ + category_id: "cat-1", + }); + + mockBookRepository.createBook.mockResolvedValue( + { + book_id: "book-1", + ...payload, + available_copies: 10, + } + ); + + const result = + await bookService.createBook(payload); + + expect( + mockCategory.findByPk + ).toHaveBeenCalledWith( + payload.category_id + ); + + expect( + mockBookRepository.createBook + ).toHaveBeenCalledWith({ + ...payload, + available_copies: 10, + }); + + expect(result.book_id).toBe("book-1"); + }); + + it("should throw when category does not exist", async () => { + mockCategory.findByPk.mockResolvedValue( + null + ); + + await expect( + bookService.createBook(payload) + ).rejects.toMatchObject({ + message: "Category not found", + statusCode: 404, + }); + + expect( + mockBookRepository.createBook + ).not.toHaveBeenCalled(); + }); + + it("should set available copies equal to total copies", async () => { + mockCategory.findByPk.mockResolvedValue({ + category_id: "cat-1", + }); + + mockBookRepository.createBook.mockResolvedValue( + {} + ); + + await bookService.createBook(payload); + + expect( + mockBookRepository.createBook + ).toHaveBeenCalledWith({ + ...payload, + available_copies: 10, + }); + }); + }); + + describe("getBookById", () => { + it("should return book", async () => { + const book = { + book_id: "book-1", + }; + + mockBookRepository.getBookById.mockResolvedValue( + book + ); + + const result = + await bookService.getBookById( + "book-1" + ); + + expect(result).toEqual(book); + }); + + it("should throw when book not found", async () => { + mockBookRepository.getBookById.mockResolvedValue( + null + ); + + await expect( + bookService.getBookById( + "book-1" + ) + ).rejects.toMatchObject({ + message: "Book not found", + statusCode: 404, + }); + }); + }); + + describe("updateBook", () => { + it("should update normal fields", async () => { + const existingBook = { + total_copies: 10, + available_copies: 6, + }; + + mockBookRepository.getBookById.mockResolvedValue( + existingBook + ); + + mockBookRepository.updateBook.mockResolvedValue( + { + book_id: "book-1", + } + ); + + await bookService.updateBook( + "book-1", + { + book_name: "Updated Book", + } + ); + + expect( + mockBookRepository.updateBook + ).toHaveBeenCalledWith( + "book-1", + { + book_name: "Updated Book", + } + ); + }); + + it("should throw when book does not exist", async () => { + mockBookRepository.getBookById.mockResolvedValue( + null + ); + + await expect( + bookService.updateBook( + "book-1", + {} + ) + ).rejects.toMatchObject({ + message: "Book not found", + statusCode: 404, + }); + }); + + it("should recalculate available copies when increasing total copies", async () => { + mockBookRepository.getBookById.mockResolvedValue( + { + total_copies: 10, + available_copies: 6, + } + ); + + await bookService.updateBook( + "book-1", + { + total_copies: 15, + } + ); + + expect( + mockBookRepository.updateBook + ).toHaveBeenCalledWith( + "book-1", + { + total_copies: 15, + available_copies: 11, + } + ); + }); + + it("should recalculate available copies when decreasing total copies", async () => { + mockBookRepository.getBookById.mockResolvedValue( + { + total_copies: 10, + available_copies: 6, + } + ); + + await bookService.updateBook( + "book-1", + { + total_copies: 8, + } + ); + + expect( + mockBookRepository.updateBook + ).toHaveBeenCalledWith( + "book-1", + { + total_copies: 8, + available_copies: 4, + } + ); + }); + + it("should throw when total copies goes below active loans", async () => { + mockBookRepository.getBookById.mockResolvedValue( + { + total_copies: 10, + available_copies: 6, + } + ); + + await expect( + bookService.updateBook( + "book-1", + { + total_copies: 3, + } + ) + ).rejects.toMatchObject({ + statusCode: 400, + }); + + expect( + mockBookRepository.updateBook + ).not.toHaveBeenCalled(); + }); + }); + + describe("deleteBook", () => { + it("should delete book successfully", async () => { + mockBookRepository.getBookById.mockResolvedValue( + { + book_id: "book-1", + } + ); + + mockBookRepository.deleteBook.mockResolvedValue( + 1 + ); + + await bookService.deleteBook( + "book-1" + ); + + expect( + mockBookRepository.deleteBook + ).toHaveBeenCalledWith( + "book-1" + ); + }); + + it("should throw when book not found", async () => { + mockBookRepository.getBookById.mockResolvedValue( + null + ); + + await expect( + bookService.deleteBook( + "book-1" + ) + ).rejects.toMatchObject({ + message: "Book not found", + statusCode: 404, + }); + }); + }); + + describe("searchBooks", () => { + it("should return empty array for empty search", async () => { + const result = + await bookService.searchBooks( + "" + ); + + expect(result).toEqual([]); + }); + + it("should trim search token before repository call", async () => { + mockBookRepository.searchBooks.mockResolvedValue( + [] + ); + + await bookService.searchBooks( + " clean code " + ); + + expect( + mockBookRepository.searchBooks + ).toHaveBeenCalledWith( + "clean code" + ); + }); + }); + + describe("getCategories", () => { + it("should return categories", async () => { + const categories = [ + { + id: "cat-1", + name: "Programming", + }, + ]; + + mockBookRepository.getCategories.mockResolvedValue( + categories + ); + + const result = + await bookService.getCategories(); + + expect(result).toEqual( + categories + ); + }); + }); +}); \ No newline at end of file diff --git a/server/src/modules/categories/categories.spec.ts b/server/src/modules/categories/categories.spec.ts index 8cfe438..f9e49b4 100644 --- a/server/src/modules/categories/categories.spec.ts +++ b/server/src/modules/categories/categories.spec.ts @@ -68,6 +68,7 @@ describe( "Programming", booksCount: 10, lendingCount: 25, + totalCopies: 0, }, ], totalCount: 5, @@ -107,6 +108,58 @@ describe( } ); + it("should sanitize aggregate string values", async () => { + mockCategoryRepository.getCategoriesWithMetrics.mockResolvedValue({ + rows: [ + { + category_id: "cat-1", + booksCount: "12", + totalCopies: "50", + lendingCount: "9", + }, + ], + count: 1, + }); + + const result = + await categoriesService.getAllCategoriesWithMetrics( + 1, + 10 + ); + + expect(result.rows[0]).toMatchObject({ + booksCount: 12, + totalCopies: 50, + lendingCount: 9, + }); +}); + +it("should default aggregate values to zero", async () => { + mockCategoryRepository.getCategoriesWithMetrics.mockResolvedValue({ + rows: [ + { + category_id: "cat-1", + booksCount: null, + totalCopies: null, + lendingCount: null, + }, + ], + count: 1, + }); + + const result = + await categoriesService.getAllCategoriesWithMetrics( + 1, + 10 + ); + + expect(result.rows[0]).toMatchObject({ + booksCount: 0, + totalCopies: 0, + lendingCount: 0, + }); +}); + it( "should pass filters correctly", async () => { @@ -260,7 +313,7 @@ describe( "Backend", } ); -expect(result?.category_id).toBe("cat-1"); + expect(result?.category_id).toBe("cat-1"); } ); diff --git a/server/src/modules/fines/fine.spec.ts b/server/src/modules/fines/fine.spec.ts index f49c3c4..015dba1 100644 --- a/server/src/modules/fines/fine.spec.ts +++ b/server/src/modules/fines/fine.spec.ts @@ -12,6 +12,41 @@ jest.unstable_mockModule("./fine.repository.js", () => ({ }, })); +jest.unstable_mockModule("../../database/index.js", () => ({ + sequelize: { + transaction: jest.fn(), + }, +})); + +jest.unstable_mockModule( + "../../database/models/Issue.js", + () => ({ + default: { + findByPk: jest.fn(), + update: jest.fn(), + }, + }) +); + +jest.unstable_mockModule( + "../../database/models/Book.js", + () => ({ + default: { + increment: jest.fn(), + decrement: jest.fn(), + }, + }) +); + +jest.unstable_mockModule( + "../../database/models/Fine.js", + () => ({ + default: { + update: jest.fn(), + }, + }) +); + const { default: fineService } = await import("./fine.service.js"); @@ -21,10 +56,32 @@ const { default: fineRepository } = const mockFineRepository = fineRepository as any; +const { sequelize } = + await import("../../database/index.js"); + +const { default: Fine } = + await import("../../database/models/Fine.js"); + +const { default: Issue } = + await import("../../database/models/Issue.js"); + +const { default: Book } = + await import("../../database/models/Book.js"); + +const mockSequelize = sequelize as any; +const mockFineModel = Fine as any; +const mockIssueModel = Issue as any; +const mockBookModel = Book as any; + describe("FineService Unit Tests", () => { - beforeEach(() => { - jest.clearAllMocks(); +beforeEach(() => { + jest.clearAllMocks(); + + mockSequelize.transaction.mockResolvedValue({ + commit: jest.fn(), + rollback: jest.fn(), }); +}); const fineRecord = { fine_id: "fine-1", @@ -162,33 +219,54 @@ describe("FineService Unit Tests", () => { describe("payFine", () => { it("should pay fine successfully", async () => { - const paidFine = { - ...fineRecord, - paid_status: true, - }; + const transaction = { + commit: jest.fn(), + rollback: jest.fn(), + }; - mockFineRepository - .getFineById - .mockResolvedValue(fineRecord); + mockSequelize.transaction.mockResolvedValue( + transaction + ); - mockFineRepository - .payFine - .mockResolvedValue(paidFine); + mockFineRepository.getFineById + .mockResolvedValueOnce(fineRecord) + .mockResolvedValueOnce({ + ...fineRecord, + paid_status: true, + }); - const result = - await fineService.payFine( - "fine-1", - "2026-01-10", - "UPI" - ); + mockFineModel.update.mockResolvedValue([1]); - expect( - mockFineRepository.payFine - ).toHaveBeenCalled(); + mockIssueModel.findByPk.mockResolvedValue({ + issue_id: "issue-1", + issue_status: "OVERDUE", + book_id: "book-1", + }); - expect(result.paid_status) - .toBe(true); - }); + mockIssueModel.update.mockResolvedValue([1]); + + mockBookModel.increment.mockResolvedValue( + [1] + ); + + const result = + await fineService.payFine( + "fine-1", + "2026-01-10", + "UPI" + ); + + expect( + mockFineModel.update + ).toHaveBeenCalled(); + + expect( + transaction.commit + ).toHaveBeenCalled(); + + expect(result.paid_status) + .toBe(true); +}); it("should use current date when paidDate is null", async () => { const paidFine = { @@ -211,8 +289,8 @@ describe("FineService Unit Tests", () => { ); expect( - mockFineRepository.payFine - ).toHaveBeenCalled(); + mockFineModel.update +).toHaveBeenCalled(); }); it("should throw when fine not found", async () => { @@ -325,38 +403,52 @@ describe("FineService Unit Tests", () => { describe("restoreFine", () => { it("should restore fine successfully", async () => { - mockFineRepository - .getFineById - .mockResolvedValue(fineRecord); + const transaction = { + commit: jest.fn(), + rollback: jest.fn(), + }; - mockFineRepository - .restoreFine - .mockResolvedValue([1]); + mockSequelize.transaction.mockResolvedValue( + transaction + ); - mockFineRepository - .getFineById - .mockResolvedValueOnce( - fineRecord - ) - .mockResolvedValueOnce({ - ...fineRecord, - paid_status: false, - }); + mockFineRepository.getFineById + .mockResolvedValueOnce(fineRecord) + .mockResolvedValueOnce({ + ...fineRecord, + paid_status: false, + }); - const result = - await fineService.restoreFine( - "fine-1" - ); + mockFineModel.update.mockResolvedValue([1]); - expect( - mockFineRepository.restoreFine - ).toHaveBeenCalledWith( - "fine-1" - ); + mockIssueModel.findByPk.mockResolvedValue({ + issue_id: "issue-1", + issue_status: "RETURNED", + book_id: "book-1", + }); - expect(result.paid_status) - .toBe(false); - }); + mockIssueModel.update.mockResolvedValue([1]); + + mockBookModel.decrement.mockResolvedValue( + [1] + ); + + const result = + await fineService.restoreFine( + "fine-1" + ); + + expect( + mockFineModel.update + ).toHaveBeenCalled(); + + expect( + transaction.commit + ).toHaveBeenCalled(); + + expect(result.paid_status) + .toBe(false); +}); it("should throw when fine id missing", async () => { await expect( @@ -387,11 +479,9 @@ describe("FineService Unit Tests", () => { .getFineById .mockResolvedValue(fineRecord); - mockFineRepository - .restoreFine - .mockRejectedValue( - new Error("DB Failure") - ); + mockFineModel.update.mockRejectedValue( + new Error("DB Failure") +); await expect( fineService.restoreFine( diff --git a/server/src/modules/members/member.spec.ts b/server/src/modules/members/member.spec.ts index 0ae3072..4d1722d 100644 --- a/server/src/modules/members/member.spec.ts +++ b/server/src/modules/members/member.spec.ts @@ -9,6 +9,7 @@ jest.unstable_mockModule("./member.repository.js", () => ({ deleteMemberRepository: jest.fn(), searchMembersByNameRepository: jest.fn(), getEligibleUsersForMemberRepository: jest.fn(), + getAllPlansWithMetrics: jest.fn(), // NEW })); jest.unstable_mockModule("../../database/models/Member.js", () => ({ @@ -45,6 +46,7 @@ const { deleteMemberRepository, searchMembersByNameRepository, getEligibleUsersForMemberRepository, + getAllPlansWithMetrics, } = await import("./member.repository.js"); const { default: Member } = diff --git a/server/src/modules/plans/plans.spec.ts b/server/src/modules/plans/plans.spec.ts index 89e8684..87d46aa 100644 --- a/server/src/modules/plans/plans.spec.ts +++ b/server/src/modules/plans/plans.spec.ts @@ -17,6 +17,15 @@ jest.unstable_mockModule("./plans.repository.js", () => ({ PlansRepository: jest.fn(() => mockRepository), })); +jest.unstable_mockModule( + "../../database/models/Member.js", + () => ({ + default: { + update: jest.fn(), + }, + }) +); + jest.unstable_mockModule( "../../database/models/MembershipPlan.js", () => ({ @@ -36,6 +45,12 @@ const { default: MembershipPlan } = await import( "../../database/models/MembershipPlan.js" ); +const { default: Member } = await import( + "../../database/models/Member.js" +); + +const mockMember = Member as any; + const mockMembershipPlan = MembershipPlan as any; // ============================================================================ @@ -53,10 +68,13 @@ const samplePlan = { describe("PlansService Unit Tests", () => { let plansService: any; - beforeEach(() => { - jest.clearAllMocks(); - plansService = new PlansService(); - }); + beforeEach(() => { + jest.clearAllMocks(); + + mockMember.update.mockResolvedValue([1]); + + plansService = new PlansService(); +}); // ========================================================================== // listAllPlans @@ -166,6 +184,10 @@ describe("PlansService Unit Tests", () => { 1, ]); + mockMember.update.mockResolvedValueOnce([1]) + .mockResolvedValueOnce([1]) + .mockResolvedValueOnce([1]); + const result = await plansService.editPlan( updatePayload diff --git a/server/src/tests/books/book.test.ts b/server/src/tests/books/book.test.ts index 5be684e..376abd0 100644 --- a/server/src/tests/books/book.test.ts +++ b/server/src/tests/books/book.test.ts @@ -95,7 +95,8 @@ describe("📚 Books Inventory Module Integration Tests", () => { book_name: `Integration Test Book ${timestamp}`, book_author: "Author Assignment Test Node", category_id: seededCategoryId, - total_copies: "15" // Passed as string to verify Zod's `z.coerce.number()` handling + total_copies: "15", + language: "English" }); expect(response.status).toBe(201); From b35afa4efc95122ff5ea7cee912233722225e5fb Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 16 Jun 2026 15:23:51 +0530 Subject: [PATCH 05/11] fix: Make all test cases passed successfully --- .../fines/components/FineDetailsModal.tsx | 6 +- .../fines/components/RestoreFineModal.tsx | 2 +- client/src/features/fines/pages/FinesPage.tsx | 616 +++++++++--------- .../issues/components/IssueDetailsModal.tsx | 44 +- .../issues/pages/TransactionsPage.tsx | 63 +- .../features/members/pages/MembersPage.tsx | 2 +- .../membershipPlans/pages/ManagePlan.tsx | 4 +- .../returnedbooks/pages/ReturnedBooks.tsx | 13 +- client/src/layouts/DashboardLayout.tsx | 224 +++---- client/src/routes/AppRoutes.tsx | 4 +- server/src/modules/fines/fine.controller.ts | 23 +- server/src/modules/fines/fine.routes.ts | 6 +- 12 files changed, 478 insertions(+), 529 deletions(-) diff --git a/client/src/features/fines/components/FineDetailsModal.tsx b/client/src/features/fines/components/FineDetailsModal.tsx index c9353ed..3d9e1e6 100644 --- a/client/src/features/fines/components/FineDetailsModal.tsx +++ b/client/src/features/fines/components/FineDetailsModal.tsx @@ -114,7 +114,7 @@ export const FineDetailsModal = ({
- Checkout Trigger Date + Borrowed Date {fine.borrowedDate} @@ -140,7 +140,7 @@ export const FineDetailsModal = ({ - Standard Plan Rate + Plan Active {breakdown.withinPlanDays}d @@ -152,7 +152,7 @@ export const FineDetailsModal = ({ {breakdown.outsidePlanDays > 0 && ( - Out-of-Plan Climax + Plan Expired {breakdown.outsidePlanDays}d diff --git a/client/src/features/fines/components/RestoreFineModal.tsx b/client/src/features/fines/components/RestoreFineModal.tsx index cba0973..cc66048 100644 --- a/client/src/features/fines/components/RestoreFineModal.tsx +++ b/client/src/features/fines/components/RestoreFineModal.tsx @@ -51,7 +51,7 @@ export const RestoreFineModal = ({ diff --git a/client/src/features/fines/pages/FinesPage.tsx b/client/src/features/fines/pages/FinesPage.tsx index 049d3b6..53292c6 100644 --- a/client/src/features/fines/pages/FinesPage.tsx +++ b/client/src/features/fines/pages/FinesPage.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; // 👈 Added useNavigate import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; import type { FineRecord } from "../../../types/fines"; @@ -17,11 +18,8 @@ import { ChevronDown, History, BookOpen, - AlertCircle, - AlertTriangle, RefreshCw, RotateCcw, - // X, User, CreditCard, CheckCircle2, @@ -35,13 +33,14 @@ interface AxiosErrorResponse { }; } -export const FinesPage = () => { +export const FinePage = () => { const queryClient = useQueryClient(); + const location = useLocation(); + const navigate = useNavigate(); // 👈 Initialized hook instance + const [selectedFine, setSelectedFine] = useState(null); - const [showRestoreModal, setShowRestoreModal] = useState(false); - const [activeHeaderDropdown, setActiveHeaderDropdown] = useState< - "delay" | null ->(null); + const [showRestoreModal, setShowRestoreModal] = useState(false); // 👈 Now actively used below + const [activeHeaderDropdown, setActiveHeaderDropdown] = useState<"delay" | null>(null); // Active View Tab Panel Layout Selector ("active" | "history") const [activeTab, setActiveTab] = useState<"active" | "history">("active"); @@ -55,23 +54,91 @@ export const FinesPage = () => { const rowsPerPage = 10; // Modals Core Management States - const [selectedFineForSettlement, setSelectedFineForSettlement] = - useState(null); + const [selectedFineForSettlement, setSelectedFineForSettlement] = useState(null); + + // 🟢 NEW MUTATION NODE: Forces dynamic backend recalculation on mount + const syncLedgerMutation = useMutation({ + mutationFn: async () => { + const response = await axiosClient.patch("/fines/recalculate-ledger"); + return response.data; + }, + onSuccess: (res) => { + console.log(`[Sync Engine] ${res.message || "Metrics synchronized successfully."}`, res.data); + // Cleanly invalidate matching queries to download the newly computed numbers + queryClient.invalidateQueries({ queryKey: ["finesMasterLedgerFeed"] }); + }, + onError: () => { + toast.error("Fine sync ledger recalculation engine structural warning."); + } + }); + + // 🟢 MOUNT LIFECYCLE ENGINE: Triggers every single time a clerk opens or views this page + useEffect(() => { + console.log("⚡ Fines Management Desk Mounted. Dispatching master calculation tool..."); + syncLedgerMutation.mutate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Fires exactly once per unique view load event // 1. Fetch Data Stream conditionally depending on active tab view layouts - const { data: finesFeedPayload = [], isLoading } = useQuery({ + const { data: finesFeedPayload = [], isLoading: isQueryLoading } = useQuery({ queryKey: ["finesMasterLedgerFeed", activeTab], queryFn: async () => { - const endpoint = - activeTab === "active" ? "/fines/pending" : "/fines/collected"; + const endpoint = activeTab === "active" ? "/fines/pending" : "/fines/collected"; const response = await axiosClient.get(endpoint); return response.data?.data || response.data || []; }, }); + // 🟢 COMBINED LOADING EVALUATION: Keeps the pulse screen running until the sync finishes + const isLoading = isQueryLoading || syncLedgerMutation.isPending; + + // Intercept incoming routing state redirect signatures safely + useEffect(() => { + const routeState = location.state as { autoOpenIssueId?: string; autoOpenSettlement?: boolean } | null; + + if (routeState) { + console.log("======== [DEBUG RECEIVER] ROUTE STATE DETECTED ========"); + console.log("Received routeState:", routeState); + } + + if (routeState?.autoOpenIssueId && finesFeedPayload.length > 0) { + const incomingId = routeState.autoOpenIssueId; + + const matchingFine = finesFeedPayload.find((fine) => { + if (!fine) return false; + return ( + fine.issue_id === incomingId || + fine.fine_id === incomingId + ); + }); + + if (matchingFine) { + console.log("✅ SUCCESS: Found a matching fine record object!", matchingFine); + + const timeoutId = setTimeout(() => { + if (routeState.autoOpenSettlement) { + setSelectedFineForSettlement(matchingFine); + setActiveTab("active"); + } else { + setSelectedFine(matchingFine); + setActiveTab("active"); + } + + // 💡 FIXED: Clean state variables inside React Router history memory safely + navigate(location.pathname, { replace: true, state: null }); + }, 0); + + return () => clearTimeout(timeoutId); + } else { + console.error( + `❌ MATCH FAIL: Checked all ${finesFeedPayload.length} ledger entries but found no matching ID for "${incomingId}".` + ); + } + } + }, [location.state, finesFeedPayload, navigate, location.pathname]); // Added strict dependencies + const restoreFineMutation = useMutation({ - mutationFn: async (id: string) => - await axiosClient.patch(`/fines/restore/${id}`), + mutationFn: async (id: string) => await axiosClient.patch(`/fines/restore/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["finesMasterLedgerFeed"] }); setShowRestoreModal(false); @@ -106,15 +173,13 @@ export const FinesPage = () => { queryClient.invalidateQueries({ queryKey: ["finesMasterLedgerFeed"] }); setSelectedFineForSettlement(null); toast.success("💸 Invoice Ledger Balanced Successfully!", { - description: - "Transaction finalized. This record has moved safely to Collected History.", + description: "Transaction finalized. This record has moved safely to Collected History.", duration: 4000, }); }, onError: (err: AxiosErrorResponse) => { toast.error( - err?.response?.data?.message || - "Execution engine rejected settlement input parameters.", + err?.response?.data?.message || "Execution engine rejected settlement input parameters." ); }, }); @@ -131,31 +196,31 @@ export const FinesPage = () => { }, }); - // 4. Multi-tier filtering context pipeline logic const filteredFines = finesFeedPayload.filter((fine) => { + if (!fine) return false; + const term = searchQuery.toLowerCase().trim(); - const nameMatch = (fine?.memberName || "").toLowerCase().includes(term); - const titleMatch = (fine?.bookTitle || "").toLowerCase().includes(term); + + const nameMatch = String(fine.memberName || "").toLowerCase().includes(term); + const titleMatch = String(fine.bookTitle || "").toLowerCase().includes(term); const passesSearch = term === "" || nameMatch || titleMatch; let passesDelayRange = true; - if (activeTab === "active" && delayIntervalFilter) { - if (delayIntervalFilter === "7") passesDelayRange = fine.delayed_days > 7; - if (delayIntervalFilter === "14") - passesDelayRange = fine.delayed_days > 14; - if (delayIntervalFilter === "30") - passesDelayRange = fine.delayed_days > 30; + if (activeTab === "active" && delayIntervalFilter && typeof fine.delayed_days === 'number') { + const days = fine.delayed_days; + if (delayIntervalFilter === "7") passesDelayRange = days > 7; + if (delayIntervalFilter === "14") passesDelayRange = days > 14; + if (delayIntervalFilter === "30") passesDelayRange = days > 30; } return passesSearch && passesDelayRange; }); // 5. Aggregate metrics computation logic blocks - const totalUnpaidInvoicesCount = - activeTab === "active" ? finesFeedPayload.length : 0; + const totalUnpaidInvoicesCount = activeTab === "active" ? finesFeedPayload.length : 0; const aggregateAccruedSumVal = filteredFines.reduce( (sum, current) => sum + (current.fine_amount || 0), - 0, + 0 ); // 6. Inline dynamic pagination offsets @@ -163,7 +228,7 @@ export const FinesPage = () => { const totalPagesCount = Math.ceil(totalItemsCount / rowsPerPage) || 1; const paginatedRowsData = filteredFines.slice( (currentPage - 1) * rowsPerPage, - currentPage * rowsPerPage, + currentPage * rowsPerPage ); const handleTabChange = (tab: "active" | "history") => { @@ -180,15 +245,16 @@ export const FinesPage = () => { }; return ( -
{/* Dynamic Header View Deck */} -
-
-

- Fines Management Desk -

+
+ {/* Dynamic Header View Deck */} +
+
+
+ Fines Management Desk +
+

Fines Management Desk

- Realtime data syncing. Automatic accrual rates apply dynamically at - 12:00 AM nightly: Active Plans (₹10/day) | Expired Plans (₹20/day). + Active Plans (₹10/day) | Expired Plans (₹20/day).

@@ -197,10 +263,8 @@ export const FinesPage = () => {
- {totalItemsCount} Settled Invoices In - Archive Ledger + {totalItemsCount} Settled Invoices In Archive Ledger
)} {/* Search Filter Control Grid */} - - - {/* copy */} -
-
-
-
-
- - - - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" - /> - {/* {searchQuery && ( - - )} */} -
- - -
- -
-
- {/* pasted */} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> +
- +
+ +
+
{/* Central Interactive Data Core Grid Matrix */} {isLoading ? ( @@ -308,164 +349,148 @@ export const FinesPage = () => { Syncing Master Banking Ledger Channels...
) : ( -
-
- - +
+
+
+ - + All Delays + + + + + + + + + )} + - {paginatedRowsData.length === 0 ? ( + + {paginatedRowsData.length === 0 ? ( - ) : ( paginatedRowsData.map((fine) => ( setSelectedFine(fine)} - className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" > + onClick={() => { + setSelectedFine(fine); + if (activeTab === "history") { + setShowRestoreModal(true); // 👈 Added execution hook triggers + } + }} + className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" + > @@ -476,21 +501,21 @@ export const FinesPage = () => { via {fine.paymentMethod} )} - + @@ -501,40 +526,45 @@ export const FinesPage = () => { {/* Pagination Command Module */} -
- Page {currentPage} / {totalPagesCount}{" "} - | Total{" "} - {totalItemsCount} Fines +
+ + Page {currentPage} / {totalPagesCount} |{" "} + Total {totalItemsCount} Fines
- + type="button" + disabled={currentPage === 1} + onClick={(e) => { + e.stopPropagation(); + setCurrentPage((p) => p - 1); + }} + className="text-gray-600 font-semibold tracking-wider disabled:opacity-20 cursor-pointer hover:text-[#2B6CB0] flex items-center gap-1 transition-colors" + > + ← Previous + +
)} - {/* Active View Logic */} + {/* ========================================== */} + {/* MODAL MOUNTING PORTALS & LAYOUTS */} + {/* ========================================== */} + + {/* 1. Active Tab Details Modal */} {activeTab === "active" && ( { /> )} - {/* History View Logic - Updated Modal Implementation with Explicit Reminder Notice */} - {activeTab === "history" && selectedFine && ( -
-
-

- {" "} - Restoration Warning -

-

- {selectedFine.memberName} -

- - {/* ENHANCED EXPLICIT INLINE INSTRUCTION REMINDER BOX FOR LIBRARIANS */} -
-

- Operational Reminder -

-

- Restoring this fine resets it to unpaid status. This balance - tracker is bound to a closed book loan. -

-

- Please visit the{" "} - - Returned Books - {" "} - panel afterwards to manually click "Undo Return" if the physical - asset is still out of the building. -

-
- -
- - -
-
-
- )} - + {/* 2. Collected History Tab Restoration Modal */} setShowRestoreModal(false)} + onClose={() => { + setSelectedFine(null); + setShowRestoreModal(false); + }} onConfirm={(id) => restoreFineMutation.mutate(id)} /> + {/* 3. Settlement Processing Invoice Form Portal */} setSelectedFineForSettlement(null)} - onConfirmSettlement={(payload: { - id: string; - paidDate: string; - paymentMethod?: string; - }) => { + onConfirmSettlement={(payload) => { const resolvedMethod = payload.paymentMethod === "CARD" || payload.paymentMethod === "UPI" ? payload.paymentMethod @@ -633,4 +611,4 @@ export const FinesPage = () => { /> ); -}; +}; \ No newline at end of file diff --git a/client/src/features/issues/components/IssueDetailsModal.tsx b/client/src/features/issues/components/IssueDetailsModal.tsx index 751a56b..c65a6e0 100644 --- a/client/src/features/issues/components/IssueDetailsModal.tsx +++ b/client/src/features/issues/components/IssueDetailsModal.tsx @@ -2,9 +2,8 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; import type { BookIssueRecord } from "../../../types/transactions"; -import { useNavigate } from "react-router-dom"; // For redirecting to payments +import { useNavigate } from "react-router-dom"; -// Lucide Icons for the professional popup layout import { AlertTriangle, ArrowRight, @@ -32,11 +31,8 @@ export const IssueDetailsModal = ({ onTriggerEdit, }: IssueDetailsModalProps) => { const navigate = useNavigate(); - - // 🟢 State control for our new embedded warning modal layout const [showFineBlockModal, setShowFineBlockModal] = useState(false); - // ✨ Fetch stats with explicit key mapping and lifecycle tracking const { data: memberStats, isLoading: isLoadingStats } = useQuery({ queryKey: ["memberHistoricalReturnsCount", record?.memberId], queryFn: async () => { @@ -51,34 +47,28 @@ export const IssueDetailsModal = ({ if (!isOpen || !record) return null; - // ✨ Feature: Safely parse and isolate the last 4 characters of the transaction UUID const formattedIssueId = record.id && record.id.length >= 4 ? `ISSUE-${record.id.slice(-4).toUpperCase()}` : `ISSUE-${record.id}`; - // 🟢 Intercept Return Action to validate financial status const handleReturnClick = () => { - // Check if there is an active outstanding fine on this specific record const hasUnpaidFine = record.fineAmount && record.fineAmount > 0 && !record.finePaidStatus; if (hasUnpaidFine) { - // Catch the restriction instantly and display the professional layout block modal setShowFineBlockModal(true); } else { - // No issues found, proceed with normal parent execution pipeline onMarkAsReturned(record.id); } }; - return ( + return ( <> - {/* Primary Issue Details Window Desk Layer - Updated with reference background, text colors, and font properties */} + {/* Primary Issue Details Window */}
- {/* Header Block Panel - Matched with reference framework layout */}

@@ -97,11 +87,10 @@ export const IssueDetailsModal = ({

- {/* Detailed Metadata Body Context Frame - Upgraded text-colors and layout spacing rules */}
- {/* Member Meta Information Sub-Card */} + {/* Member Profile */}
@@ -135,7 +124,7 @@ export const IssueDetailsModal = ({
- {/* Book Catalog Details Section */} + {/* Book Details */}
@@ -151,7 +140,7 @@ export const IssueDetailsModal = ({
- {/* Timeline Parameters Matrix */} + {/* Timeline Grid */}
@@ -177,7 +166,7 @@ export const IssueDetailsModal = ({
- {/* Control Terminal Footer - Using matching layout rules and primary brand button colors */} + {/* Action Buttons */}
- {/* 🟢 NEW SECONDARY PORTAL LAYER: Professional Unpaid Fine Blocking Warning Pop-Up */} + {/* SECONDARY PORTAL LAYER: Fine Blocking Warning Pop-Up */} {showFineBlockModal && (
-
+
- {/* Warning Header block matches standard system alerts */}

@@ -233,7 +221,6 @@ export const IssueDetailsModal = ({

- {/* Warning Body Parameters */}

The library core system cannot authorize this inventory shelf @@ -265,12 +252,11 @@ export const IssueDetailsModal = ({ Overdue Debt: - ₹{record.fineAmount}.00 + ₹{record.fineAmount}

- {/* Policy Card Rule layout alignment with reference parameters */}
Policy Rule Verification @@ -293,12 +279,10 @@ export const IssueDetailsModal = ({ @@ -309,4 +293,4 @@ export const IssueDetailsModal = ({ )} ); -}; +}; \ No newline at end of file diff --git a/client/src/features/issues/pages/TransactionsPage.tsx b/client/src/features/issues/pages/TransactionsPage.tsx index 252827d..5d5bacd 100644 --- a/client/src/features/issues/pages/TransactionsPage.tsx +++ b/client/src/features/issues/pages/TransactionsPage.tsx @@ -155,13 +155,12 @@ export const TransactionsPage = () => { {/* Upper Control Bar Layout */}
+
+ Lending Management Desk +

Borrow & Return Desk

-

- Manage real-time out-of-building media assets, process drop-offs, - and track compliance. -

- + @@ -303,23 +302,23 @@ export const TransactionsPage = () => { }} className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" > - - - - - - - - - + + ); + }) + )} + +
- Account Holder Member + Memeber info Media Asset Context - + + + {activeHeaderDropdown === "delay" && ( +
+ - - {activeHeaderDropdown === "delay" && ( -
- - - - - - - -
- )} -
Fine Amount Plan Clause
- Operational Clear View. Zero matching layout targets - found. + + Operational Clear View. Zero matching layout targets found.
-
- {fine.memberName} -
+
{fine.memberName}
{fine.memberEmail}
- + {fine.bookTitle}
- Due Date:{" "} - {fine.actualReturnDueDate || - fine.actualReturnDate || - "N/A"} + Due Date: {fine.actualReturnDueDate || fine.actualReturnDate || "N/A"}
{activeTab === "active" ? ( - + {fine.delayed_days} Days Overdue ) : ( - Paid ({fine.paidDate || fine.paid_date || "Settled"} - ) + Paid ({fine.paidDate || fine.paid_date || "Settled"}) )} - - - {fine.membershipActive - ? "Active Plan" - : "Plan Expired"} - + + + {fine.membershipActive ? "Active Plan" : "Plan Expired"} +
Target Due Deadline -
- - - -
-
+
+ + + +
+
+ {record.memberName} + {record.bookTitle} + {record.borrowedDate} + {record.dueDate} + { const res = await axiosClient.get("/members", { params: { page: currentPage, - limit: 1000, + limit: 10, search: searchTerm || undefined, plan: tierFilter || undefined, status: statusFilter || undefined, diff --git a/client/src/features/membershipPlans/pages/ManagePlan.tsx b/client/src/features/membershipPlans/pages/ManagePlan.tsx index ef9cbb4..76b76f3 100644 --- a/client/src/features/membershipPlans/pages/ManagePlan.tsx +++ b/client/src/features/membershipPlans/pages/ManagePlan.tsx @@ -141,7 +141,7 @@ export const ManagePlan = () => { {plansPayload?.globalActiveMembers ?? 0} - Active Plans + Active Members
@@ -150,7 +150,7 @@ export const ManagePlan = () => { {plansPayload?.globalInactiveMembers ?? 0} - Inactive Plans + Inactive Members
diff --git a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx index a5abd6d..0f7ac32 100644 --- a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx +++ b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx @@ -218,9 +218,6 @@ export const ReturnedBooks = () => {

Returned Books Management Desk

-

- Review completed book returns, manage archiving, and track overdue fine status ledgers. -

@@ -341,7 +338,7 @@ export const ReturnedBooks = () => { }} className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" > -
+
{record.bookTitle}
@@ -350,25 +347,25 @@ export const ReturnedBooks = () => {
+
{record.memberName}
+
{record.borrowedDate}
+
{record.dueDate}
+ {record.returnedDate} diff --git a/client/src/layouts/DashboardLayout.tsx b/client/src/layouts/DashboardLayout.tsx index 8121ab1..5c46778 100644 --- a/client/src/layouts/DashboardLayout.tsx +++ b/client/src/layouts/DashboardLayout.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { Outlet, NavLink, useNavigate, useLocation } from "react-router-dom"; import { useAuthStore } from "../store/authStore"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion } from "framer-motion"; // Lucide Icons with balanced stroke weight for clean, institutional clarity import { @@ -16,7 +16,6 @@ import { LogOut, Library, User, - Menu, } from "lucide-react"; export const DashboardLayout = () => { @@ -24,32 +23,11 @@ export const DashboardLayout = () => { const navigate = useNavigate(); const location = useLocation(); - // State engine managing navigation flyout and dashboard scroll adjustments - const [menuOpen, setMenuOpen] = useState(false); - const [isScrolled, setIsScrolled] = useState(false); + // State engine managing navigation side bar width expansion configuration + const [sidebarExpanded, setSidebarExpanded] = useState(false); const mainScrollContainerRef = useRef(null); - // Layout Rule: Transparent header states apply only at zero-scroll offset on the primary dashboard - const isDashboardPage = location.pathname === "/dashboard" || location.pathname === "/"; - - // Watch scroll container progression to toggle header canvas states dynamically - useEffect(() => { - const scrollEl = mainScrollContainerRef.current; - if (!scrollEl) return; - - const handleScrollUpdate = () => { - if (scrollEl.scrollTop > 20) { - setIsScrolled(true); - } else { - setIsScrolled(false); - } - }; - - scrollEl.addEventListener("scroll", handleScrollUpdate); - return () => scrollEl.removeEventListener("scroll", handleScrollUpdate); - }, [location.pathname]); - // Handle immediate viewport layout reset on route change useEffect(() => { if (mainScrollContainerRef.current) { @@ -73,37 +51,27 @@ export const DashboardLayout = () => { { name: "Fines & Payments", path: "/fines", icon: Receipt }, ]; - // Configure operational header styling based on page context and scroll threshold - const getHeaderStyles = () => { - if (isDashboardPage) { - return isScrolled - ? "fixed top-0 left-0 right-0 bg-[#1A365D] border-b border-[#E2E8F0]/10 shadow-sm text-white" - : "absolute top-0 left-0 right-0 bg-transparent border-b border-transparent text-white"; - } - return "relative bg-[#1A365D] border-b border-[#E2E8F0]/10 shadow-sm text-white"; - }; - return ( -
+
- {/* Institutional Top Application Header Bar */} -
+ {/* Institutional Top Application Header Bar - Always visible, persistent foundation layout */} +
- {/* Core Navigation Cluster */} -
- + {/* Core Navigation Brand Click Area */} +
setSidebarExpanded(!sidebarExpanded)} + className="flex items-center gap-4 cursor-pointer group select-none" + title={sidebarExpanded ? "Collapse Navigation Menu" : "Expand Navigation Menu"} + > +
+ +
- + LMS - + Library Management System
@@ -113,7 +81,7 @@ export const DashboardLayout = () => {

- ROLE: {user?.role || "LIBRARIAN"} + ROLE: {user?.role || "LIBRARIAN"}

@@ -123,92 +91,90 @@ export const DashboardLayout = () => {
- {/* Slideout Shell Menu System */} - - {menuOpen && ( - <> - {/* Structural Backdrop Dimmer Mask */} - setMenuOpen(false)} - className="absolute inset-0 bg-[#1A365D]/30 backdrop-blur-xs z-40 cursor-pointer" - /> + {/* Main Structural Application Framework Split */} +
+ + {/* Persistent Left Icon/Expanded Navigation Sidebar Tracking Shell */} + +
+ {/* Navigation Routing Links */} + +
- {/* Main Full-Height Left Drawer Panel */} - + -
-
- - )} -
+ Logout + + )} + +
+ - {/* Main Document / Workspace Framework Interface Content Slot */} -
- - - -
+ + + + +
); }; \ No newline at end of file diff --git a/client/src/routes/AppRoutes.tsx b/client/src/routes/AppRoutes.tsx index 8207384..716f5ce 100644 --- a/client/src/routes/AppRoutes.tsx +++ b/client/src/routes/AppRoutes.tsx @@ -7,7 +7,7 @@ import { Login } from "../features/auth/pages/Login"; import { MembersPage } from "../features/members/pages/MembersPage"; import { BooksPage } from "../features/books/pages/BooksPage"; import { TransactionsPage } from "../features/issues/pages/TransactionsPage"; -import { FinesPage } from "../features/fines/pages/FinesPage"; +import { FinePage } from "../features/fines/pages/FinesPage"; import { ReturnedBooks } from "../features/returnedbooks/pages/ReturnedBooks"; import { ManageCategories } from "../features/categories/pages/ManageCategories"; @@ -38,7 +38,7 @@ export const AppRoutes = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/server/src/modules/fines/fine.controller.ts b/server/src/modules/fines/fine.controller.ts index b1ab28f..8b28b23 100644 --- a/server/src/modules/fines/fine.controller.ts +++ b/server/src/modules/fines/fine.controller.ts @@ -112,4 +112,25 @@ export const restoreFineController = asyncHandler( data: result, }); } -); \ No newline at end of file +); + +export const fineController = { + async triggerForceRecalculate(req: Request, res: Response) { + try { + console.log("🔔 Manual API request received: Force sync master fine metrics..."); + const result = await fineService.forceRecalculateAllExistingFines(); + + return res.status(200).json({ + success: true, + message: "Master banking ledger metrics recalculated successfully.", + data: result + }); + } catch (error: any) { + console.error("❌ Failed to force recalculate ledger matrices:", error); + return res.status(500).json({ + success: false, + message: error.message || "Internal server error execution lock fault." + }); + } + } +}; \ No newline at end of file diff --git a/server/src/modules/fines/fine.routes.ts b/server/src/modules/fines/fine.routes.ts index 51fe1e5..ae50c36 100644 --- a/server/src/modules/fines/fine.routes.ts +++ b/server/src/modules/fines/fine.routes.ts @@ -176,7 +176,8 @@ import { getMemberFinesController, payFineController, purgeFineController, - restoreFineController // Added for manual invoice clearing + restoreFineController, + fineController // Added for manual invoice clearing } from "./fine.controller.js"; import { payFineSchema, restoreFineSchema, purgeFineSchema, getMemberFinesSchema } from "./fine.validation.js"; @@ -200,6 +201,9 @@ router.get("/member/:memberId", auth, validate(getMemberFinesSchema), getMemberF // Example: In your fine.routes.ts router.patch("/restore/:id", auth, validate(restoreFineSchema), restoreFineController); +// Add a POST or PATCH endpoint specifically for manual sync triggers +router.patch("/recalculate-ledger", fineController.triggerForceRecalculate); + // 🟢 Added to handle the purgeFineMutation soft/hard delete manual overrides router.delete("/:id", auth, validate(purgeFineSchema) ,purgeFineController); From 1c1cd6aa71aad80ae9ed7a943f5e18aed1485c99 Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 16 Jun 2026 15:38:58 +0530 Subject: [PATCH 06/11] feat: add some logics to improve the user experience --- .../fines/components/RestoreFineModal.tsx | 2 +- .../issues/pages/TransactionsPage.tsx | 362 +++++++------- .../returnedbooks/pages/ReturnedBooks.tsx | 442 +++++++++--------- 3 files changed, 388 insertions(+), 418 deletions(-) diff --git a/client/src/features/fines/components/RestoreFineModal.tsx b/client/src/features/fines/components/RestoreFineModal.tsx index cc66048..3028b30 100644 --- a/client/src/features/fines/components/RestoreFineModal.tsx +++ b/client/src/features/fines/components/RestoreFineModal.tsx @@ -51,7 +51,7 @@ export const RestoreFineModal = ({ diff --git a/client/src/features/issues/pages/TransactionsPage.tsx b/client/src/features/issues/pages/TransactionsPage.tsx index 5d5bacd..c76ca3a 100644 --- a/client/src/features/issues/pages/TransactionsPage.tsx +++ b/client/src/features/issues/pages/TransactionsPage.tsx @@ -16,9 +16,9 @@ import { Search, RotateCcw, ChevronDown, + Layers, X, Plus, - RefreshCw, User, BookOpen, Calendar, @@ -149,51 +149,57 @@ export const TransactionsPage = () => { ); }, [allFilteredRecords, safeCurrentPage, rowsPerPage]); - return ( + return (
- {/* Upper Control Bar Layout */} + {/* ==================== ZONES A & B: ALIGNED HEADER WITH METRIC STRIP ==================== */}
-
- Lending Management Desk -
-

+
+ Lending Management Desk +
+

Borrow & Return Desk -

+
- + {/* Metric Tracker Stack */} +
+
+ + {totalRecordsCount} + + + Total Books + +
+
- {/* Search Bar Controls Section */} +
+ + {/* ==================== ZONE C: MINIMALIST UTILITIES SUB HEADER ==================== */}
-
+
+ Classification Ledger
-
-
- - - - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" - /> + + {/* Compact Right-Aligned Control Blocks */} +
+ + {/* Always-On Static Rounded Search Input Field Frame */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> {searchQuery && (
- -
- -
-
- - {isLoading ? ( -
- - Syncing active book circulation ledgers... + {/* Always-On Persistent Filters Clear Action Icon Trigger */} + + +
+ + {/* Streamlined Action Core Button */} +
- ) : ( -
- - {/* Main List Workspace Table */} -
- -
- - - - - - - - - + - - { + setSelectedRecord(record); + setIsDetailsOpen(true); + }} + className="transition-all duration-150 cursor-pointer border-l-4 hover:bg-blue-50/40 border-l-transparent" + > + + + + + + + )) + )} + +
- - Member Name - - - Issued Book Title - - - Checkout Date - - Target Due Deadline - + {/* ==================== ZONE D: STATIC FULL-WIDTH TABLE DISPLAY ==================== */} +
+
+ {isLoading ? ( +
+ Syncing active book circulation ledgers... +
+ ) : ( +
+
+ + + + + + + + - - - - - {paginatedRecords.length === 0 ? ( - - - ) : ( - paginatedRecords.map((record) => ( - { - setSelectedRecord(record); - setIsDetailsOpen(true); - }} - className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" - > - - - - - - - - - + {paginatedRecords.length === 0 ? ( + + - )) - )} - -
+ Member Name + + Issued Book Title + + Checkout Date + + Target Due Deadline +
-
- No active out-of-building book logs registered on - current indexing criteria. -
- {record.memberName} - - {record.bookTitle} - - {record.borrowedDate} - - {record.dueDate} - - - {/* {record.computedStatus === "OVERDUE" && ( - - )} */} - - {record.computedStatus} - + +
+ No active out-of-building book logs registered on current indexing criteria.
-
+ ) : ( + paginatedRecords.map((record) => ( +
+ {record.memberName} + + {record.bookTitle} + + {record.borrowedDate} + + {record.dueDate} + + + {record.computedStatus} + +
+
- {totalPages > 0 && ( -
- - Page{" "} - - {currentPage} - {" "} - of{" "} - - {totalPages} + {/* Minimal Pagination Elements */} + {totalPages > 0 && ( +
+ + Page {currentPage} of{" "} + {totalPages} + | + Total {totalRecordsCount} Books - - | - - Total{" "} - - {totalRecordsCount} - {" "} - Books - - -
- - - +
+ + +
-
- )} -
+ )} +
+ )}
- )} +
{/* Modals Layers */} { }} />
-); -}; +);} \ No newline at end of file diff --git a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx index 0f7ac32..214ec8d 100644 --- a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx +++ b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx @@ -206,250 +206,238 @@ export const ReturnedBooks = () => { deleteSingleMutation.isPending || clearAllHistoryMutation.isPending; - return ( -
- - {/* ==================== ZONE A & B: HEADER & GLOBAL CONTROLS ==================== */} -
-
-
- Returned Books Management Desk -
-

- Returned Books Management Desk -

-
- -
- {totalRecordsCount > 0 && ( - - )} + return ( +
+ + {/* ==================== ZONES A & B: ALIGNED HEADER WITH METRIC STRIP / ACTIONS ==================== */} +
+
+
+ Returned Books Management Desk
+

+ Returned Books Management Desk +

- -
- - {/* ==================== ZONE C: UTILITIES FILTER ROW ==================== */} -
-
- Circulation Archives -
- -
- {/* Search Bar aligned to the right side */} -
- - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-text-main placeholder-slate-400 p-0 focus:ring-0 focus:outline-hidden" - /> -
- - {/* Date Picker Input */} -
- - { - setDateFilter(e.target.value); - setCurrentPage(1); - }} - className="bg-transparent border-0 outline-hidden w-full text-xs font-bold uppercase tracking-wider text-text-main p-0 focus:ring-0 focus:outline-hidden cursor-pointer" - /> -
- - {/* Reset Action Button */} + + {/* Dynamic Right-Aligned Global Actions Frame */} +
+ {totalRecordsCount > 0 && ( -
+ )}
+
- {/* ==================== ZONE D: GRID DISPLAY TABLE ==================== */} +
-
- {isLoading ? ( -
- Accessing historical records archives... -
- ) : ( -
-
-
- - - - - - - - - - - - - {paginatedRecords.length === 0 ? ( - - - - ) : ( - paginatedRecords.map((record) => { - return ( - { - setSelectedRecord(record); - setIsDetailsOpen(true); - }} - className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" - > - + {/* ==================== ZONE C: MINIMALIST UTILITIES SUB HEADER ==================== */} +
+
+ Circulation Archives +
-
+ {/* Compact Right-Aligned Control Blocks */} +
+ + {/* Always-On Static Rounded Search Input Field Frame */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> +
-
+ {/* Date Picker Input matching Search Frame layout */} +
+ + { + setDateFilter(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-bold uppercase tracking-wider text-[#1A365D] p-0 focus:ring-0 focus:outline-hidden cursor-pointer" + /> +
- + {/* Always-On Persistent Filters Clear Action Icon Trigger */} + + + - { + setSelectedRecord(record); + setIsDetailsOpen(true); + }} + className="transition-all duration-150 cursor-pointer border-l-4 hover:bg-blue-50/40 border-l-transparent" + > + {/* Column 1: Core Title Profile Info */} + + + {/* Column 2: Borrower Identity Info */} + + + {/* Column 3: Timeline Marker - Issued */} + + + {/* Column 4: Timeline Marker - Due */} + + + {/* Column 5: Timeline Marker - Return Action Status */} + + + ); + }) + )} + +
- Issued Book Title - - Borrower Name - - Issued On - - Target Due - - Returned On -
- No historical book returns matching the selected filter criteria. -
-
- {record.bookTitle} -
- - By {record.bookAuthor} - -
-
- {record.memberName} -
-
-
- {record.borrowedDate} -
-
-
- {record.dueDate} -
-
- - {record.returnedDate} - + {/* ==================== ZONE D: STATIC FULL-WIDTH TABLE DISPLAY ==================== */} +
+
+ {isLoading ? ( +
+ Accessing historical records archives... +
+ ) : ( +
+
+ + + + + + + + + + + + + {paginatedRecords.length === 0 ? ( + + - ); - }) - )} - -
Issued Book TitleBorrower NameIssued OnTarget DueReturned On
+ No historical book returns matching the selected filter criteria.
-
- - {totalPages > 0 && ( -
- - Page{" "} - - {currentPage} - {" "} - of{" "} - - {totalPages} - - | - Total{" "} - - {totalRecordsCount} - {" "} - Books - - -
- - - + ) : ( + paginatedRecords.map((record) => { + return ( +
+
+
+ {record.bookTitle} +
+ + By {record.bookAuthor} + +
+
+
+ {record.memberName} +
+
+
+ {record.borrowedDate} +
+
+
+ {record.dueDate} +
+
+ + {record.returnedDate} + +
+ + {/* Minimal Pagination Elements */} + {totalPages > 0 && ( +
+ + Page {currentPage} of{" "} + {totalPages} + | + Total {totalRecordsCount} Books + + +
+ + + +
+
+ )}
)}
- )} -
- - - {/* History Details Lookup Modal */} - setIsDetailsOpen(false)} - record={selectedRecord} - onUndoReturn={(id) => handleUndoReturn(id)} - onDeletePermanent={(id) => handleDeleteSingle(id)} - /> - - {/* Unified Confirmation Modal Layer */} - -
- ); -}; \ No newline at end of file + + {/* History Details Lookup Modal */} + setIsDetailsOpen(false)} + record={selectedRecord} + onUndoReturn={(id) => handleUndoReturn(id)} + onDeletePermanent={(id) => handleDeleteSingle(id)} + /> + + {/* Unified Confirmation Modal Layer */} + +
+);}; \ No newline at end of file From 71c8ab362d9f0660111aae85000072ea5b5817bb Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 16 Jun 2026 16:22:52 +0530 Subject: [PATCH 07/11] feat: add some logics to improve the user experience --- client/src/features/books/pages/BooksPage.tsx | 2 +- .../categories/pages/ManageCategories.tsx | 2 +- .../fines/components/RestoreFineModal.tsx | 2 +- client/src/features/fines/pages/FinesPage.tsx | 68 ++- .../issues/pages/TransactionsPage.tsx | 112 ++++- .../features/members/pages/MembersPage.tsx | 2 +- .../membershipPlans/pages/ManagePlan.tsx | 2 +- .../returnedbooks/pages/ReturnedBooks.tsx | 465 +++++++++--------- .../modules/dashboard/dashboard.controller.ts | 6 - 9 files changed, 379 insertions(+), 282 deletions(-) diff --git a/client/src/features/books/pages/BooksPage.tsx b/client/src/features/books/pages/BooksPage.tsx index cf5ab28..81bb72d 100644 --- a/client/src/features/books/pages/BooksPage.tsx +++ b/client/src/features/books/pages/BooksPage.tsx @@ -262,7 +262,7 @@ export const BooksPage = () => { { setSearchTerm(e.target.value); setCurrentPage(1); }} className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" diff --git a/client/src/features/categories/pages/ManageCategories.tsx b/client/src/features/categories/pages/ManageCategories.tsx index 07c6bef..8759aaa 100644 --- a/client/src/features/categories/pages/ManageCategories.tsx +++ b/client/src/features/categories/pages/ManageCategories.tsx @@ -269,7 +269,7 @@ export const ManageCategories = () => { { setSearchTerm(e.target.value); setCurrentPage(1); }} className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" diff --git a/client/src/features/fines/components/RestoreFineModal.tsx b/client/src/features/fines/components/RestoreFineModal.tsx index 3028b30..0f367a8 100644 --- a/client/src/features/fines/components/RestoreFineModal.tsx +++ b/client/src/features/fines/components/RestoreFineModal.tsx @@ -53,7 +53,7 @@ export const RestoreFineModal = ({ onClick={() => onConfirm(fine.fine_id)} className="flex-1 py-2.5 bg-[#2B6CB0] hover:bg-[#205997] text-white text-xs font-bold uppercase tracking-wider rounded-xl transition-all cursor-pointer shadow-sm" > - Restore Entry + Restore Fine
diff --git a/client/src/features/fines/pages/FinesPage.tsx b/client/src/features/fines/pages/FinesPage.tsx index 53292c6..953b7db 100644 --- a/client/src/features/fines/pages/FinesPage.tsx +++ b/client/src/features/fines/pages/FinesPage.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; // 👈 Added useNavigate +import { useState, useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; import type { FineRecord } from "../../../types/fines"; @@ -36,10 +36,10 @@ interface AxiosErrorResponse { export const FinePage = () => { const queryClient = useQueryClient(); const location = useLocation(); - const navigate = useNavigate(); // 👈 Initialized hook instance + const navigate = useNavigate(); const [selectedFine, setSelectedFine] = useState(null); - const [showRestoreModal, setShowRestoreModal] = useState(false); // 👈 Now actively used below + const [showRestoreModal, setShowRestoreModal] = useState(false); const [activeHeaderDropdown, setActiveHeaderDropdown] = useState<"delay" | null>(null); // Active View Tab Panel Layout Selector ("active" | "history") @@ -56,6 +56,24 @@ export const FinePage = () => { // Modals Core Management States const [selectedFineForSettlement, setSelectedFineForSettlement] = useState(null); + // Ref tracking node for catching outside clicks on table header elements + const delayDropdownRef = useRef(null); + + // Global Outside Dropdown Click Catcher Hook + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + activeHeaderDropdown === "delay" && + delayDropdownRef.current && + !delayDropdownRef.current.contains(event.target as Node) + ) { + setActiveHeaderDropdown(null); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [activeHeaderDropdown]); + // 🟢 NEW MUTATION NODE: Forces dynamic backend recalculation on mount const syncLedgerMutation = useMutation({ mutationFn: async () => { @@ -64,7 +82,6 @@ export const FinePage = () => { }, onSuccess: (res) => { console.log(`[Sync Engine] ${res.message || "Metrics synchronized successfully."}`, res.data); - // Cleanly invalidate matching queries to download the newly computed numbers queryClient.invalidateQueries({ queryKey: ["finesMasterLedgerFeed"] }); }, onError: () => { @@ -77,7 +94,7 @@ export const FinePage = () => { console.log("⚡ Fines Management Desk Mounted. Dispatching master calculation tool..."); syncLedgerMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Fires exactly once per unique view load event + }, []); // 1. Fetch Data Stream conditionally depending on active tab view layouts const { data: finesFeedPayload = [], isLoading: isQueryLoading } = useQuery({ @@ -124,7 +141,6 @@ export const FinePage = () => { setActiveTab("active"); } - // 💡 FIXED: Clean state variables inside React Router history memory safely navigate(location.pathname, { replace: true, state: null }); }, 0); @@ -135,7 +151,7 @@ export const FinePage = () => { ); } } - }, [location.state, finesFeedPayload, navigate, location.pathname]); // Added strict dependencies + }, [location.state, finesFeedPayload, navigate, location.pathname]); const restoreFineMutation = useMutation({ mutationFn: async (id: string) => await axiosClient.patch(`/fines/restore/${id}`), @@ -264,7 +280,7 @@ export const FinePage = () => { type="button" onClick={() => handleTabChange("active")} className={`flex items-center gap-1.5 px-3.5 py-4 text-xs font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer ${ - activeTab === "active" ? "bg-card-bg shadow-xs" : "text-slate-500 hover:text-slate-800" + activeTab === "active" ? "bg-white shadow-xs text-[#1A365D]" : "text-slate-500 hover:text-slate-800" }`} > Active Defaulters @@ -274,7 +290,7 @@ export const FinePage = () => { onClick={() => handleTabChange("history")} className={`flex items-center gap-1.5 px-3.5 py-2 text-xs font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer ${ activeTab === "history" - ? "bg-card-bg shadow-xs" + ? "bg-white shadow-xs text-[#1A365D]" : "text-slate-500 hover:text-slate-800" }`} > @@ -318,7 +334,7 @@ export const FinePage = () => { type="text" placeholder={ activeTab === "active" - ? "Search active balances by name or title strings..." + ? "Search by book or member..." : "Search historical collections..." } value={searchQuery} @@ -354,15 +370,15 @@ export const FinePage = () => { - - - - - + + @@ -463,7 +482,7 @@ export const FinePage = () => { onClick={() => { setSelectedFine(fine); if (activeTab === "history") { - setShowRestoreModal(true); // 👈 Added execution hook triggers + setShowRestoreModal(true); } }} className="transition-all duration-150 cursor-pointer border-l-4 border-l-transparent hover:bg-blue-50/40" @@ -537,23 +556,22 @@ export const FinePage = () => { disabled={currentPage === 1} onClick={(e) => { e.stopPropagation(); - setCurrentPage((p) => p - 1); + setCurrentPage((p) => Math.max(p - 1, 1)); }} className="text-gray-600 font-semibold tracking-wider disabled:opacity-20 cursor-pointer hover:text-[#2B6CB0] flex items-center gap-1 transition-colors" > - ← Previous + ← Previous @@ -582,7 +600,7 @@ export const FinePage = () => { {/* 2. Collected History Tab Restoration Modal */} { setSelectedFine(null); diff --git a/client/src/features/issues/pages/TransactionsPage.tsx b/client/src/features/issues/pages/TransactionsPage.tsx index c76ca3a..32903a8 100644 --- a/client/src/features/issues/pages/TransactionsPage.tsx +++ b/client/src/features/issues/pages/TransactionsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { axiosClient } from "../../../api/axiosClient"; import { TransactionModal } from "../components/TransactionModal"; @@ -34,12 +34,33 @@ export const TransactionsPage = () => { const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; + // Clean UI state parameters matching Members layout + const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false); + const [isFormOpen, setIsFormOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [selectedRecord, setSelectedRecord] = useState( null, ); + // Refs for tracking outside dropdown clicks + const statusDropdownRef = useRef(null); + + // Close interactive headers if clicking outside + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + isStatusDropdownOpen && + statusDropdownRef.current && + !statusDropdownRef.current.contains(event.target as Node) + ) { + setIsStatusDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [isStatusDropdownOpen]); + // Fetch Master Feed Data Ledger const { data: rawIssues = [], isLoading } = useQuery({ queryKey: ["circulationMasterRecordsFeed", token], @@ -192,7 +213,7 @@ export const TransactionsPage = () => { { setSearchQuery(e.target.value); @@ -266,27 +287,72 @@ export const TransactionsPage = () => { - @@ -319,12 +385,13 @@ export const TransactionsPage = () => { - @@ -401,4 +468,5 @@ export const TransactionsPage = () => { }} /> -);} \ No newline at end of file + ); +}; \ No newline at end of file diff --git a/client/src/features/members/pages/MembersPage.tsx b/client/src/features/members/pages/MembersPage.tsx index d152785..6760132 100644 --- a/client/src/features/members/pages/MembersPage.tsx +++ b/client/src/features/members/pages/MembersPage.tsx @@ -268,7 +268,7 @@ export const MembersPage = () => { { setSearchTerm(e.target.value); setCurrentPage(1); }} className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" diff --git a/client/src/features/membershipPlans/pages/ManagePlan.tsx b/client/src/features/membershipPlans/pages/ManagePlan.tsx index 76b76f3..89db0fd 100644 --- a/client/src/features/membershipPlans/pages/ManagePlan.tsx +++ b/client/src/features/membershipPlans/pages/ManagePlan.tsx @@ -169,7 +169,7 @@ export const ManagePlan = () => { { setSearchTerm(e.target.value); setCurrentPage(1); }} className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" diff --git a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx index 214ec8d..09dc465 100644 --- a/client/src/features/returnedbooks/pages/ReturnedBooks.tsx +++ b/client/src/features/returnedbooks/pages/ReturnedBooks.tsx @@ -6,12 +6,7 @@ import type { BookIssueRecord } from "../../../types/transactions"; import { toast } from "sonner"; import { useAuthStore } from "../../../store/authStore"; import ConfirmationModal from "../components/ConfirmationModal"; -import { - Search, - Calendar, - RotateCcw, - Trash2, -} from "lucide-react"; +import { Search, Calendar, RotateCcw, Trash2 } from "lucide-react"; export const ReturnedBooks = () => { const queryClient = useQueryClient(); @@ -206,238 +201,260 @@ export const ReturnedBooks = () => { deleteSingleMutation.isPending || clearAllHistoryMutation.isPending; - return ( -
- - {/* ==================== ZONES A & B: ALIGNED HEADER WITH METRIC STRIP / ACTIONS ==================== */} -
-
-
- Returned Books Management Desk + return ( +
+ {/* ==================== ZONES A & B: ALIGNED HEADER WITH METRIC STRIP / ACTIONS ==================== */} +
+
+
+ Returned Books Management Desk +
+

+ Returned Books Management Desk +

-

- Returned Books Management Desk -

-
- - {/* Dynamic Right-Aligned Global Actions Frame */} -
- {totalRecordsCount > 0 && ( - - )} -
-
-
- - {/* ==================== ZONE C: MINIMALIST UTILITIES SUB HEADER ==================== */} -
-
- Circulation Archives + {/* Dynamic Right-Aligned Global Actions Frame */} +
+ {totalRecordsCount > 0 && ( + + )} +
- {/* Compact Right-Aligned Control Blocks */} -
- - {/* Always-On Static Rounded Search Input Field Frame */} -
- - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" - /> -
+
- {/* Date Picker Input matching Search Frame layout */} -
- - { - setDateFilter(e.target.value); - setCurrentPage(1); - }} - className="bg-transparent border-0 outline-hidden w-full text-xs font-bold uppercase tracking-wider text-[#1A365D] p-0 focus:ring-0 focus:outline-hidden cursor-pointer" - /> + {/* ==================== ZONE C: MINIMALIST UTILITIES SUB HEADER ==================== */} +
+
+ Circulation Archives
- {/* Always-On Persistent Filters Clear Action Icon Trigger */} - -
-
+ {/* Compact Right-Aligned Control Blocks */} +
+ {/* Always-On Static Rounded Search Input Field Frame */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-hidden" + /> +
- {/* ==================== ZONE D: STATIC FULL-WIDTH TABLE DISPLAY ==================== */} -
-
- {isLoading ? ( -
- Accessing historical records archives... + {/* Date Picker Input matching Search Frame layout */} +
+ + { + setDateFilter(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-hidden w-full text-xs font-bold uppercase tracking-wider text-[#1A365D] p-0 focus:ring-0 focus:outline-hidden cursor-pointer" + />
- ) : ( -
-
-
+ - Memeber info + Member info + Media Asset Context + {activeHeaderDropdown === "delay" && ( -
+
)}
Fine AmountPlan ClauseFine AmountPlan Clause
Target Due Deadline -
- +
+ + + {isStatusDropdownOpen && ( +
+ + + +
+ )}
{record.dueDate} + + {record.computedStatus}
- - - - - - - - - - - - {paginatedRecords.length === 0 ? ( - - + + {/* Always-On Persistent Filters Clear Action Icon Trigger */} + + + + + {/* ==================== ZONE D: STATIC FULL-WIDTH TABLE DISPLAY ==================== */} +
+
+ {isLoading ? ( +
+ Accessing historical records archives... +
+ ) : ( +
+
+
Issued Book TitleBorrower NameIssued OnTarget DueReturned On
- No historical book returns matching the selected filter criteria. -
+ + + + + + + - ) : ( - paginatedRecords.map((record) => { - return ( - { - setSelectedRecord(record); - setIsDetailsOpen(true); - }} - className="transition-all duration-150 cursor-pointer border-l-4 hover:bg-blue-50/40 border-l-transparent" + + + + {paginatedRecords.length === 0 ? ( + + + + ) : ( + paginatedRecords.map((record) => { + return ( + { + setSelectedRecord(record); + setIsDetailsOpen(true); + }} + className="transition-all duration-150 cursor-pointer border-l-4 hover:bg-blue-50/40 border-l-transparent" + > + {/* Column 1: Core Title Profile Info */} + + + {/* Column 2: Borrower Identity Info */} + + + {/* Column 3: Timeline Marker - Issued */} + + + {/* Column 4: Timeline Marker - Due */} + + + {/* Column 5: Timeline Marker - Return Action Status */} + - - {/* Column 2: Borrower Identity Info */} - - - {/* Column 3: Timeline Marker - Issued */} - - - {/* Column 4: Timeline Marker - Due */} - - - {/* Column 5: Timeline Marker - Return Action Status */} - - - ); - }) - )} - -
+ Issued Book Title + + Borrower Name + + Issued On + + Target Due + + Returned On +
- {/* Column 1: Core Title Profile Info */} - -
-
- {record.bookTitle} + No historical book returns matching the selected + filter criteria. +
+
+
+ {record.bookTitle} +
+ + By {record.bookAuthor} + +
+
+
+ {record.memberName}
- - By {record.bookAuthor} +
+
+ {record.borrowedDate} +
+
+
+ {record.dueDate} +
+
+ + {record.returnedDate} - - -
- {record.memberName} -
-
-
- {record.borrowedDate} -
-
-
- {record.dueDate} -
-
- - {record.returnedDate} - -
-
+
+
- {/* Minimal Pagination Elements */} - {totalPages > 0 && ( -
- - Page {currentPage} of{" "} - {totalPages} - | - Total {totalRecordsCount} Books - - -
- - - + {/* Minimal Pagination Elements */} + {totalPages > 0 && ( +
+ + Page{" "} + + {currentPage} + {" "} + of{" "} + + {totalPages} + + | + Total{" "} + + {totalRecordsCount} + {" "} + Books + + +
+ + + +
-
- )} -
- )} + )} +
+ )} +
-
- {/* History Details Lookup Modal */} - setIsDetailsOpen(false)} - record={selectedRecord} - onUndoReturn={(id) => handleUndoReturn(id)} - onDeletePermanent={(id) => handleDeleteSingle(id)} - /> - - {/* Unified Confirmation Modal Layer */} - -
-);}; \ No newline at end of file + {/* History Details Lookup Modal */} + setIsDetailsOpen(false)} + record={selectedRecord} + onUndoReturn={(id) => handleUndoReturn(id)} + onDeletePermanent={(id) => handleDeleteSingle(id)} + /> + + {/* Unified Confirmation Modal Layer */} + +
+ ); +}; diff --git a/server/src/modules/dashboard/dashboard.controller.ts b/server/src/modules/dashboard/dashboard.controller.ts index 11aeec7..d369aea 100644 --- a/server/src/modules/dashboard/dashboard.controller.ts +++ b/server/src/modules/dashboard/dashboard.controller.ts @@ -14,12 +14,6 @@ export const getDashboardSummaryController = asyncHandler( async (req: Request, res: Response): Promise => { // 💡 Executing the centralized class instance method const result: CompleteDashboardSummaryResponse = await dashboardService.getDashboardSummaryService(); - - console.log("=== 🛰️ BACKEND DASHBOARD SUMMARY PAYLOAD ==="); - console.log("Total Books (Titles Count):", result.summary.totalBooks); - console.log("Total Copies (Physical Sum):", result.summary.totalCopies); - console.log("Available Books (Shelf Sum):", result.summary.availableBooks); - console.log("============================================"); // Return formatted using JSend convention format to feed TanStack query smoothly return res.status(200).json({ success: true, From ca58f4ab76783b6110b67d7568d32d8e6f7aaa2d Mon Sep 17 00:00:00 2001 From: Yogeshwaran S Date: Tue, 16 Jun 2026 17:37:32 +0530 Subject: [PATCH 08/11] fix: solev the issue in azure ocr to detect multiple langauges and return a summary about the book with author name and book name --- .../features/books/components/BookModal.tsx | 212 +++++++++++------- .../modules/azureAI/aiScanner.controller.ts | 8 +- .../src/modules/azureAI/aiScanner.routes.ts | 2 +- .../src/modules/azureAI/aiScanner.service.ts | 127 ++++++----- 4 files changed, 204 insertions(+), 145 deletions(-) diff --git a/client/src/features/books/components/BookModal.tsx b/client/src/features/books/components/BookModal.tsx index 163d29d..ac0e135 100644 --- a/client/src/features/books/components/BookModal.tsx +++ b/client/src/features/books/components/BookModal.tsx @@ -7,13 +7,11 @@ import { axiosClient } from "../../../api/axiosClient"; import { toast } from "sonner"; // Editorial Visual Assets -import { Sparkles } from "lucide-react"; +import { Sparkles, BookOpen, Layers } from "lucide-react"; -interface ScoredLine { - originalText: string; - translatedText: string; - category: "green" | "yellow" | "red"; - reason: string; +interface BookAiInsights { + category: string; + overview: string; } interface BookModalProps { @@ -32,14 +30,13 @@ export const BookModal = ({ editingBook, }: BookModalProps) => { const [isScanning, setIsScanning] = useState(false); - const [ocrAlternatives, setOcrAlternatives] = useState([]); + const [aiInsights, setAiInsights] = useState(null); const [scanCounter, setScanCounter] = useState(() => { const saved = localStorage.getItem("dev_scan_counter"); return saved ? parseInt(saved, 10) : 0; }); - // Default values set to clean base states, letting the useEffect handle pre-filling on open const { register, handleSubmit, @@ -57,7 +54,6 @@ export const BookModal = ({ }, }); - // Safe Synchronization Cycle: Triggers updates explicitly when the target references alter useEffect(() => { if (isOpen) { if (editingBook) { @@ -69,15 +65,13 @@ export const BookModal = ({ categoryId: editingBook.categoryId, }); } else { - // Keeps the form clean when triggering "Add New Book" reset({ title: "", author: "", language: "", totalCopies: 1, categoryId: "" }); } } }, [editingBook, isOpen, reset]); - // Safely cleans state artifacts within action contexts outside of reactive render loops const handleCloseModal = () => { - setOcrAlternatives([]); + setAiInsights(null); reset({ title: "", author: "", language: "", totalCopies: 1, categoryId: "" }); onClose(); }; @@ -86,8 +80,8 @@ export const BookModal = ({ const file = e.target.files?.[0]; if (!file) return; - if (scanCounter >= 10) { - toast.error("Testing guard triggered: 10 item scan limit reached."); + if (scanCounter >= 50) { + toast.error("Testing guard triggered: 50 item scan limit reached."); return; } @@ -96,7 +90,7 @@ export const BookModal = ({ try { setIsScanning(true); - setOcrAlternatives([]); + setAiInsights(null); toast.loading("Analyzing book layout structures...", { id: "azure-scan", }); @@ -109,16 +103,25 @@ export const BookModal = ({ const payload = response.data; if (payload && payload.success) { - if ( - payload.alternativeLines && - Array.isArray(payload.alternativeLines) - ) { - setOcrAlternatives(payload.alternativeLines); + if (payload.overview || payload.category) { + setAiInsights({ + category: payload.category || "Non-Fiction", + overview: payload.overview || "", + }); } - // AUTOFILL EXECUTION: Hydrates form controllers directly setValue("title", payload.title || ""); setValue("author", payload.author || ""); + setValue("language", payload.language || ""); + + if (payload.category) { + const matchedCat = categories.find( + (c) => c.name.toLowerCase() === payload.category.toLowerCase() + ); + if (matchedCat) { + setValue("categoryId", matchedCat.id); + } + } const nextCount = scanCounter + 1; setScanCounter(nextCount); @@ -132,13 +135,10 @@ export const BookModal = ({ } } catch (error) { console.error(error); - - const isAxiosError = - error && typeof error === "object" && "code" in error; - const errorMsg = - isAxiosError && (error as { code: string }).code === "ECONNABORTED" - ? "Request timeout! Azure took too long." - : "Error communicating with AI parser layer."; + const isAxiosError = error && typeof error === "object" && "code" in error; + const errorMsg = isAxiosError && (error as { code: string }).code === "ECONNABORTED" + ? "Request timeout! AI process layer took too long." + : "Error communicating with AI parser layer."; toast.error(errorMsg, { id: "azure-scan" }); } finally { @@ -147,17 +147,80 @@ export const BookModal = ({ } }; + // UI Text Formatter for Metadata Strings + const renderFormattedOverview = (text: string) => { + if (!text) return null; + + // Splits content cleanly by finding keywords or explicit newlines + const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean); + + // Fixed: 'const' instead of 'let' to satisfy ESLint prefer-const rule + const details: string[] = []; + let summaryText = ""; + let insideSummary = false; + + lines.forEach((line) => { + if (line.toLowerCase().startsWith("summary:")) { + insideSummary = true; + summaryText = line.replace(/^summary:\s*/i, ""); + } else if (insideSummary) { + summaryText += " " + line; + } else { + details.push(line); + } + }); + + return ( +
+ {/* Core Metadata Elements List */} +
+ {details.map((detail, idx) => { + const splitIdx = detail.indexOf(":"); + if (splitIdx !== -1) { + const label = detail.substring(0, splitIdx).trim(); + const val = detail.substring(splitIdx + 1).trim(); + return ( +
+ {/* Fixed: Upgraded arbitrary 'min-w-[140px]' to canonical Tailwind v4 'min-w-35' */} + {label}: + {val} +
+ ); + } + return ( +

+ {detail} +

+ ); + })} +
+ + {/* Separated Summary Block Component */} + {summaryText && ( +
+
+ Abstract Summary +
+

+ "{summaryText.trim()}" +

+
+ )} +
+ ); + }; + if (!isOpen) return null; return (
0 ? "w-full max-w-4xl" : "w-full max-w-xl"}`} + className={`bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-200 flex flex-col md:flex-row max-h-[90vh] transition-all duration-300 ${aiInsights ? "w-full max-w-4xl" : "w-full max-w-xl"}`} > {/* LEFT COMPONENT: Primary Form Input Layout */}
- {/* Header Framework - Matching Reference Module Layout */} + {/* Header Framework */}

@@ -187,31 +250,30 @@ export const BookModal = ({ Intelligent OCR Engine - Scans: {scanCounter}/10 + Scans: {scanCounter}/50

= 10} + disabled={isScanning || scanCounter >= 50} 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-[#2B6CB0] file:text-white hover:file:bg-[#1A365D] transition-all cursor-pointer" />
)}
- - {/* Core Info Properties Fields */}
+ {/* Fixed: outline-none replaces custom token constraints */} {errors.title && (

@@ -224,10 +286,11 @@ export const BookModal = ({ + {/* Fixed: outline-none replaces custom token constraints */} {errors.author && (

@@ -240,10 +303,11 @@ export const BookModal = ({ + {/* Fixed: outline-none replaces custom token constraints */}

@@ -252,10 +316,11 @@ export const BookModal = ({ + {/* Fixed: outline-none replaces custom token constraints */}
@@ -263,9 +328,10 @@ export const BookModal = ({ Category Classification
+ {/* Fixed: outline-none replaces custom token constraints */} setName(e.target.value)} - className={`w-full pl-9 pr-4 py-2.5 bg-slate-50 border text-text-main rounded-xl text-xs font-semibold transition-all outline-hidden focus:bg-card-bg focus:ring-4 ${ + className={`w-full pl-10 pr-4 py-2.5 bg-gray-50 border rounded-xl text-xs font-semibold outline-hidden transition-all focus:bg-white focus:border-gray-300 focus:ring-0 ${ errors.name - ? "border-rose-500 focus:ring-rose-500/5" - : "border-border-main focus:ring-slate-900/5 focus:border-slate-900" + ? "border-rose-300 focus:ring-rose-900/5 focus:border-rose-400 text-rose-900 bg-rose-50/20" + : "border-gray-200 text-[#2D3748]" }`} />
{errors.name && ( -

+

{errors.name}

)}
{/* Email Field */} -
-

+

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

- + + {/* Standardized tracker statistics blocks from reference */} +
+
+ + {totalCount} + + + Total Users + +
+
- {/* 2. Standardized Control Pipeline Filter Ribbon */} -
- {/* Search Anchored Input */} -
- - - - { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="w-full pl-10 pr-10 py-2 bg-slate-50 border border-border-main text-text-main rounded-xl text-xs sm:text-sm font-medium outline-hidden focus:bg-card-bg focus:ring-4 focus:ring-slate-900/5 focus:border-slate-900 transition-all placeholder-slate-400" - /> +
+ + {/* ==================== ZONE C: UTILITIES HEADER ==================== */} +
+
+ Users Ledger
- {/* Dropdowns Action Block */} -
- {/* Reset Filters Control */} +
+ {/* Exact standardized rounded search field element from reference menu */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + className="bg-transparent border-0 outline-none w-full text-xs font-medium text-[#1A365D] placeholder-[#A0AEC0] p-0 focus:ring-0 focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + +
+ + {/* Clean institutional plus button framework matches exactly */} +
- {/* 3. Central Interactive Data Matrix Table */} -
- {isLoading ? ( -
- Syncing System Account Directory... -
- ) : ( -
-
- - - - - - - - - - - - {usersList.length === 0 ? ( - - + {/* ==================== ZONE D: GRID DISPLAY VIEW ==================== */} +
+
+ {isLoading ? ( +
+ Syncing System Account Directory... +
+ ) : ( +
+
+ {/* FIX: Swapped arbitrary 'min-w-[800px]' with canonical Tailwind v4 'min-w-200' */} +
System IDUser NameEmail AddressPhone NumberEntry Date
- No active matching subscriber accounts found on server - indexing. -
+ + + + + + + - ) : ( - usersList.map((user: UserRecord) => ( - setSelectedUser(user)} - className="hover:bg-slate-50/80 transition-colors cursor-pointer group select-none" - > - - - - - + + {usersList.length === 0 ? ( + + - )) - )} - -
System IDUser NameEmail AddressPhone NumberEntry Date
- USR-{user.user_id.slice(-4)} - - {user.name} - -
- {user.gmail} -
-
-
- {user.phone_number || "No Phone Contact"} -
-
- {new Date(user.created_at).toLocaleDateString( - undefined, - { - year: "numeric", - month: "short", - day: "numeric", - }, - )} +
+ No active matching subscriber accounts found on server indexing.
-
+ ) : ( + /* FIX: Changed structural iteration hook type mapping from 'any' to explicit 'UserRecord' contract */ + usersList.map((user: UserRecord) => { + const isCurrentSelection = selectedUser?.user_id === user.user_id; + return ( + setSelectedUser(user)} + className={`transition-all duration-150 cursor-pointer border-l-4 ${ + isCurrentSelection + ? 'bg-slate-50/80 border-l-4 border-l-blue-500' + : 'hover:bg-blue-50/40 border-l-4 border-l-transparent' + }`} + > + + USR-{user.user_id.slice(-4)} + + + +
+
+ {user.name ? user.name.charAt(0).toUpperCase() : "U"} +
+ + {user.name} + +
+ + + + {user.gmail} + + + + {user.phone_number || "No Phone Contact"} + - {/* 4. Pagination Navigation Footer Deck */} -
- - Page {currentPage} / {totalPages}{" "} - | Total{" "} - {totalCount} Users - -
- - + + {new Date(user.created_at).toLocaleDateString( + undefined, + { + year: "numeric", + month: "short", + day: "numeric", + }, + )} + + + ); + }) + )} + +
+ + {/* RESTORED: Pagination navigation metrics block elements under the layout frame */} + {totalPages > 0 && ( +
+ + Page {currentPage} of {totalPages} + | Total {totalCount} Users + +
+ + +
+
+ )}
-
- )} + )} +
- {/* Popup Form Modals Layers */} + {/* ==================== GLOBAL OVERLAY MODALS ==================== */} setIsModalOpen(false)} /> { />
); -}; +}; \ No newline at end of file diff --git a/client/src/features/admin/components/UserDetailsModal.tsx b/client/src/features/admin/components/UserDetailsModal.tsx index fa9037d..d326b93 100644 --- a/client/src/features/admin/components/UserDetailsModal.tsx +++ b/client/src/features/admin/components/UserDetailsModal.tsx @@ -3,13 +3,10 @@ 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"; @@ -48,7 +45,6 @@ export const UserDetailsModal: React.FC = ({ 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 @@ -64,6 +60,7 @@ export const UserDetailsModal: React.FC = ({ toast.success("User account information synchronized successfully."); queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); setIsEditing(false); + onClose(); }, onError: (error: AxiosError) => { toast.error( @@ -110,14 +107,6 @@ export const UserDetailsModal: React.FC = ({ 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; }; @@ -129,7 +118,6 @@ export const UserDetailsModal: React.FC = ({ name: name.trim(), gmail: gmail.trim().toLowerCase(), phone_number: phoneNumber, - password: password, }); }; @@ -137,7 +125,6 @@ export const UserDetailsModal: React.FC = ({ setName(user.name); setGmail(user.gmail); setPhoneNumber(user.phone_number); - setPassword(user.password || ""); setErrors({}); setIsEditing(false); }; @@ -149,16 +136,16 @@ export const UserDetailsModal: React.FC = ({ return ( <> - {/* High contrast layout backdrop with frosting filter matching MemberDetailsModal */} -
-
- {/* Header Grid Framework - Clean Bright Banner matching screen formats */} -
+
+
+ + {/* Header Framework */} +
-

+

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

-

+

ID: USR- {user.user_id ? user.user_id.split("-").pop()?.slice(-4).toUpperCase() @@ -168,40 +155,40 @@ export const UserDetailsModal: React.FC = ({

{/* 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-card-bg focus:ring-4 ${ + 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-border-main text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + : "border-gray-200 text-[#2D3748] focus:ring-slate-900/5 focus:border-slate-400" }`} />
) : ( - + {user.name} )} @@ -212,36 +199,36 @@ export const UserDetailsModal: React.FC = ({ )}
- {isEditing &&
} +
- {/* Dynamic Interactive Input Grid Setup */} + {/* Dynamic Interactive Grid Layout */}
{/* 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-card-bg focus:ring-4 ${ + 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-border-main text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + : "border-gray-200 text-[#2D3748] focus:ring-slate-900/5 focus:border-slate-400" }`} />
) : ( - + {user.gmail} )} @@ -253,14 +240,14 @@ export const UserDetailsModal: React.FC = ({
{/* Phone Number Entry Section */} -
- +
+ Phone Number {isEditing ? ( -
+
= ({ onChange={(e) => 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-card-bg focus:ring-4 ${ + 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-border-main text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" + : "border-gray-200 text-[#2D3748] focus:ring-slate-900/5 focus:border-slate-400" }`} />
) : ( - - {user.phone_number || "No Verified Phone"} + + {user.phone_number || "—"} )} {errors.phoneNumber && ( @@ -289,93 +276,58 @@ export const UserDetailsModal: React.FC = ({ )}
- {/* 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-card-bg 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-border-main text-slate-800 focus:ring-slate-900/5 focus:border-slate-400" - }`} - /> -
- ) : ( - - {user.password || "••••••••"} + {/* Password Section - Only rendered when read-only */} + {!isEditing && ( +
+ + Password - )} - {errors.password && ( -

- {errors.password} -

- )} -
+ + •••••••• + +
+ )}
{!isEditing && ( <> -
- - {/* Immutable Metadata Dashboard Blocks */} -
-
- -
- - Security Role Status - - +
+
+
+ + Security Role Status + +
+ {user.role}
-
- -
- - System Enrollment Date - - - {new Date(user.created_at).toLocaleDateString( - undefined, - { year: "numeric", month: "long", day: "numeric" }, - )} - -
+
+ + System Enrollment Date + + + {new Date(user.created_at).toLocaleDateString( + undefined, + { year: "numeric", month: "long", day: "numeric" }, + )} +
)}
- {/* Operations Actions Layout Interface Tray Footer */} -
+ {/* Tray Footer Operations Actions */} +
{isEditing ? ( <> @@ -383,7 +335,7 @@ export const UserDetailsModal: React.FC = ({ type="button" onClick={handleUpdateSubmit} disabled={updateMutation.isPending} - className="px-5 py-3 bg-slate-900 hover:bg-slate-800 text-amber-50 text-xs font-bold uppercase tracking-wide rounded-xl transition-all disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed cursor-pointer shadow-sm flex items-center justify-center gap-1.5" + className="px-5 py-2.5 bg-[#2B6CB0] hover:bg-[#1A365D] text-white text-xs font-bold rounded-full transition-all cursor-pointer shadow-sm text-center tracking-wide flex items-center justify-center gap-1.5" > {" "} {updateMutation.isPending @@ -392,11 +344,11 @@ export const UserDetailsModal: React.FC = ({ ) : ( -
+ <> @@ -404,18 +356,17 @@ export const UserDetailsModal: React.FC = ({ -
+ )}
- {/* Secure Action Delete Confirmation Sub-modal stack overlay */} setIsDeleteModalOpen(false)} @@ -425,4 +376,4 @@ export const UserDetailsModal: React.FC = ({ /> ); -}; +}; \ No newline at end of file diff --git a/client/src/features/admin/components/UserModal.tsx b/client/src/features/admin/components/UserModal.tsx index bf08498..2d02d76 100644 --- a/client/src/features/admin/components/UserModal.tsx +++ b/client/src/features/admin/components/UserModal.tsx @@ -10,50 +10,76 @@ interface BackendErrorResponse { message: string; } +interface UserRecord { + user_id: string; + name: string; + gmail: string; + phone_number: string; +} + interface UserModalProps { isOpen: boolean; onClose: () => void; + initialData?: UserRecord | null; } -export const UserModal: React.FC = ({ isOpen, onClose }) => { +export const UserModal: React.FC = ({ isOpen, onClose, initialData }) => { const queryClient = useQueryClient(); + const isEditMode = !!initialData; - // 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(""); + // Form Fields State tracking parameters - Derived synchronously on mount. No useEffect required. + const [name, setName] = useState(() => initialData?.name || ""); + const [gmail, setGmail] = useState(() => initialData?.gmail || ""); + const [password, setPassword] = useState(() => isEditMode ? "••••••••" : ""); + const [confirmPassword, setConfirmPassword] = useState(() => isEditMode ? "••••••••" : ""); + const [phoneNumber, setPhoneNumber] = useState(() => initialData?.phone_number || ""); // Error tracking vectors const [errors, setErrors] = useState>({}); - // Reset helper invoked during form completion or dismissal - const handleForcedReset = () => { + const handleResetFields = () => { setName(""); setGmail(""); setPassword(""); setConfirmPassword(""); setPhoneNumber(""); setErrors({}); + }; + + const handleForcedReset = () => { + handleResetFields(); onClose(); }; - // React Query Mutation handler to execute the POST endpoint handshake - const addUserMutation = useMutation({ + // Dual Action Mutation Handler: Routes between edit-user and add-user routes + const userMutation = useMutation({ mutationFn: async (payload: Record) => { - const response = await axiosClient.post("/admin/add-user", payload); - return response.data; + if (isEditMode && initialData) { + const response = await axiosClient.put(`/admin/edit-user/${initialData.user_id}`, payload); + return response.data; + } else { + const response = await axiosClient.post("/admin/add-user", payload); + return response.data; + } }, onSuccess: () => { - toast.success("New library reader account provisioned successfully."); + toast.success( + isEditMode + ? "Operator profile updated successfully." + : "New library operator provisioned successfully." + ); + + // INSTANT LIVE REFRESH: Force-refresh feeds to update the grid layout cards instantly queryClient.invalidateQueries({ queryKey: ["adminUsersMasterFeed"] }); + queryClient.invalidateQueries({ queryKey: ["readers"] }); + queryClient.invalidateQueries({ queryKey: ["operators"] }); + handleForcedReset(); }, onError: (error: AxiosError) => { - console.error("Account Creation Failed:", error); + console.error("Account Operation Failed:", error); const serverMessage = error.response?.data?.message; - toast.error(serverMessage || "Failed to finalize account registry."); + toast.error(serverMessage || "Failed to finalize account registry context changes."); }, }); @@ -66,31 +92,28 @@ export const UserModal: React.FC = ({ isOpen, onClose }) => { 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."; + 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."; - } + // Require validation rules ONLY on standard creation pipelines + if (!isEditMode) { + 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."; + 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."; + localErrors.phoneNumber = "Phone connectivity baseline mapping is required."; } else if (!phoneRegex.test(phoneNumber)) { - localErrors.phoneNumber = - "Must register an absolute 10-digit numeric line string."; + localErrors.phoneNumber = "Must register an absolute 10-digit numeric line string."; } setErrors(localErrors); @@ -101,215 +124,209 @@ export const UserModal: React.FC = ({ isOpen, onClose }) => { e.preventDefault(); if (!validateForm()) return; - addUserMutation.mutate({ + const payload: Record = { name: name.trim(), gmail: gmail.trim().toLowerCase(), - password, phone_number: phoneNumber, - role: "READER", - }); + role: "OPERATOR", // Set dynamically to match management panel requirement contexts + }; + + if (!isEditMode) { + payload.password = password; + } + + userMutation.mutate(payload); }; if (!isOpen) return null; return ( -
-
- {/* Modal Branding Header - Slate-900 Core Banner Matching MemberModal */} -
+
+
+ + {/* Modal Branding Header */} +
-

- Add New Reader Profile +

+ {isEditMode ? "Edit User Profile" : "Add New Operator Profile"}

-

- All system configuration inputs are mandatory + {initialData?.user_id && ( +

+ ID: {initialData.user_id} +

+ )} +

+ {isEditMode ? "Update data configuration parameters" : "All system configuration inputs are mandatory"}

{/* Input Interactive form area */} - + + {/* Full Name Input */} -
-