diff --git a/webapp/src/app/api/member-lookup/route.ts b/webapp/src/app/api/member-lookup/route.ts index 577e850..f2d2b8a 100644 --- a/webapp/src/app/api/member-lookup/route.ts +++ b/webapp/src/app/api/member-lookup/route.ts @@ -63,18 +63,54 @@ export async function GET(req: Request) { }); } - // Calculate plan status (active/expired) + // Calculate plan status based on individual services (active/expired) const currentTime = Math.floor(Date.now() / 1000); - const planStatus = member.planExpAt_unix > currentTime ? 'active' : 'expired'; + const activeServices = member.services?.filter(service => + service.active && service.planExpAt_unix > currentTime + ) || []; - // Prepare the response data with only the required fields + const expiredServices = member.services?.filter(service => + !service.active || service.planExpAt_unix <= currentTime + ) || []; + + const planStatus = activeServices.length > 0 ? 'active' : 'expired'; + + // Get the latest expiration date from active services + const latestExpiration = activeServices.length > 0 + ? Math.max(...activeServices.map(s => s.planExpAt_unix)) + : member.planExpAt_unix; + + // Prepare detailed services information + const servicesInfo = member.services?.map(service => ({ + serviceID: service.serviceID, + serviceName: service.serviceName, + registeredAt: service.registeredAt, + registeredAt_unix: service.registeredAt_unix, + planDelay: service.planDelay, + planDelay_unix: service.planDelay_unix, + planExpAt: service.planExpAt, + planExpAt_unix: service.planExpAt_unix, + planUpdatedAt: service.planUpdatedAt, + planUpdatedAt_unix: service.planUpdatedAt_unix, + active: service.active, + status: (service.active && service.planExpAt_unix > currentTime) ? 'active' : 'expired', + daysRemaining: service.planExpAt_unix > currentTime + ? Math.ceil((service.planExpAt_unix - currentTime) / (24 * 60 * 60)) + : 0 + })) || []; + + // Prepare the response data with detailed services information const memberInfo = { name: `${member.firstName} ${member.lastName}`, gender: member.gendre, planDelay: member.planDelay, planStart: member.planUpdatedAt, planStatus: planStatus, - planExpAt: member.planExpAt + planExpAt: new Date(latestExpiration * 1000).toUTCString(), + activeServicesCount: activeServices.length, + expiredServicesCount: expiredServices.length, + totalServicesCount: member.services?.length || 0, + services: servicesInfo }; // Return the member information diff --git a/webapp/src/app/api/user/actions/equipments/route.ts b/webapp/src/app/api/user/actions/equipments/route.ts index dd541d5..5c571fa 100644 --- a/webapp/src/app/api/user/actions/equipments/route.ts +++ b/webapp/src/app/api/user/actions/equipments/route.ts @@ -73,7 +73,7 @@ export async function GET(req:Request) { // get the page const page : string | null = searchParams.get('page') ? searchParams.get('page') : '0', - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // get the docs const docs = await equipmentModel.find({}).skip(range[0]).limit(range[1]), // get the size of the docs @@ -141,7 +141,7 @@ export async function DELETE(req:Request) const { searchParams } = new URL(req.url) const page = searchParams.get('page'), _id : string | null = searchParams.get('_id'), - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // delete the doc await equipmentModel.findByIdAndRemove(_id) // get the docs by page diff --git a/webapp/src/app/api/user/actions/expenses/route.ts b/webapp/src/app/api/user/actions/expenses/route.ts index 34d9130..29114d6 100644 --- a/webapp/src/app/api/user/actions/expenses/route.ts +++ b/webapp/src/app/api/user/actions/expenses/route.ts @@ -70,9 +70,9 @@ export async function GET(req:Request) { // get the page const page : string | null = searchParams.get('page') ? searchParams.get('page') : '0', - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; - // get the docs - const docs = await expenseModel.find({}).skip(range[0]).limit(range[1]), + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; + // get the docs (sorted by addedAt in descending order to show newest first) + const docs = await expenseModel.find({}).sort({ addedAt: -1 }).skip(range[0]).limit(range[1]), // get the size of the docs docs_count = await expenseModel.countDocuments({}); // prepare the return data @@ -90,14 +90,14 @@ export async function GET(req:Request) { // get the searchKeyword param let searchKeyword = searchParams.get('searchKeyword') - // get the search results docs + // get the search results docs (sorted by addedAt in descending order) let results = await expenseModel.find({ $or: [ // search by ( case insensitive search ) { name: { $regex: searchKeyword, $options: 'i' } }, { description: { $regex: searchKeyword, $options: 'i' } } ] - }) + }).sort({ addedAt: -1 }) // get the docs let responseData = { docs: results, @@ -136,7 +136,7 @@ export async function DELETE(req:Request) const { searchParams } = new URL(req.url) const page = searchParams.get('page'), _id : string | null = searchParams.get('_id'), - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // update the outcome // get the old amount let doc = await expenseModel.findById(_id) @@ -148,8 +148,8 @@ export async function DELETE(req:Request) }) // delete the doc await expenseModel.findByIdAndRemove(_id) - // get the docs by page - const docs = await expenseModel.find({}).skip(range[0]).limit(range[1]), + // get the docs by page (sorted by addedAt in descending order) + const docs = await expenseModel.find({}).sort({ addedAt: -1 }).skip(range[0]).limit(range[1]), // get the size of the docs docs_count = await expenseModel.countDocuments({}); diff --git a/webapp/src/app/api/user/actions/income-expense-analytics/route.ts b/webapp/src/app/api/user/actions/income-expense-analytics/route.ts new file mode 100644 index 0000000..196fdaa --- /dev/null +++ b/webapp/src/app/api/user/actions/income-expense-analytics/route.ts @@ -0,0 +1,167 @@ +import dbConnect from "@/database/dbConnect"; +import { NextResponse } from "next/server"; +import incomeModel from "@/database/models/incomeModel"; +import expenseModel from "@/database/models/expenseModel"; + +// GET METHOD - Fetch monthly and yearly income/expense analytics +export async function GET(req: Request) { + try { + await dbConnect(); + + const url = new URL(req.url); + const view = url.searchParams.get('view') || 'monthly'; // 'monthly' or 'yearly' + const year = parseInt(url.searchParams.get('year') || new Date().getFullYear().toString()); + const month = parseInt(url.searchParams.get('month') || (new Date().getMonth() + 1).toString()); + + let data = {}; + + if (view === 'monthly') { + // Get monthly data for the specified year + const monthlyData = []; + + for (let m = 1; m <= 12; m++) { + const startOfMonth = new Date(year, m - 1, 1); + const endOfMonth = new Date(year, m, 0, 23, 59, 59, 999); + + const startUnix = Math.floor(startOfMonth.getTime() / 1000); + const endUnix = Math.floor(endOfMonth.getTime() / 1000); + + // Aggregate income for this month + const incomeResult = await incomeModel.aggregate([ + { + $match: { + addedAt: { $gte: startUnix, $lte: endUnix } + } + }, + { + $group: { + _id: null, + totalIncome: { $sum: "$amount" }, + count: { $sum: 1 } + } + } + ]); + + // Aggregate expenses for this month + const expenseResult = await expenseModel.aggregate([ + { + $match: { + addedAt: { $gte: startUnix, $lte: endUnix } + } + }, + { + $group: { + _id: null, + totalExpense: { $sum: "$amount" }, + count: { $sum: 1 } + } + } + ]); + + monthlyData.push({ + month: m, + monthName: new Date(year, m - 1).toLocaleString('default', { month: 'long' }), + income: incomeResult[0]?.totalIncome || 0, + expense: expenseResult[0]?.totalExpense || 0, + incomeCount: incomeResult[0]?.count || 0, + expenseCount: expenseResult[0]?.count || 0, + net: (incomeResult[0]?.totalIncome || 0) - (expenseResult[0]?.totalExpense || 0) + }); + } + + data = { + view: 'monthly', + year, + months: monthlyData, + totalIncome: monthlyData.reduce((sum, month) => sum + month.income, 0), + totalExpense: monthlyData.reduce((sum, month) => sum + month.expense, 0), + totalNet: monthlyData.reduce((sum, month) => sum + month.net, 0) + }; + + } else if (view === 'yearly') { + // Get yearly data for the last 5 years + const currentYear = new Date().getFullYear(); + const yearlyData = []; + + for (let y = currentYear - 4; y <= currentYear; y++) { + const startOfYear = new Date(y, 0, 1); + const endOfYear = new Date(y, 11, 31, 23, 59, 59, 999); + + const startUnix = Math.floor(startOfYear.getTime() / 1000); + const endUnix = Math.floor(endOfYear.getTime() / 1000); + + // Aggregate income for this year + const incomeResult = await incomeModel.aggregate([ + { + $match: { + addedAt: { $gte: startUnix, $lte: endUnix } + } + }, + { + $group: { + _id: null, + totalIncome: { $sum: "$amount" }, + count: { $sum: 1 } + } + } + ]); + + // Aggregate expenses for this year + const expenseResult = await expenseModel.aggregate([ + { + $match: { + addedAt: { $gte: startUnix, $lte: endUnix } + } + }, + { + $group: { + _id: null, + totalExpense: { $sum: "$amount" }, + count: { $sum: 1 } + } + } + ]); + + yearlyData.push({ + year: y, + income: incomeResult[0]?.totalIncome || 0, + expense: expenseResult[0]?.totalExpense || 0, + incomeCount: incomeResult[0]?.count || 0, + expenseCount: expenseResult[0]?.count || 0, + net: (incomeResult[0]?.totalIncome || 0) - (expenseResult[0]?.totalExpense || 0) + }); + } + + data = { + view: 'yearly', + years: yearlyData, + totalIncome: yearlyData.reduce((sum, year) => sum + year.income, 0), + totalExpense: yearlyData.reduce((sum, year) => sum + year.expense, 0), + totalNet: yearlyData.reduce((sum, year) => sum + year.net, 0) + }; + } + + return NextResponse.json({ + success: true, + message: "Analytics data retrieved successfully", + data + }, { + status: 200, + headers: { + "content-type": "application/json" + } + }); + + } catch (error) { + console.error("Error fetching income/expense analytics:", error); + return NextResponse.json({ + success: false, + message: "Server error" + }, { + status: 500, + headers: { + "content-type": "application/json" + } + }); + } +} \ No newline at end of file diff --git a/webapp/src/app/api/user/actions/incomes/route.ts b/webapp/src/app/api/user/actions/incomes/route.ts index 6e7b65f..ea64163 100644 --- a/webapp/src/app/api/user/actions/incomes/route.ts +++ b/webapp/src/app/api/user/actions/incomes/route.ts @@ -70,9 +70,10 @@ export async function GET(req:Request) { // get the page const page : string | null = searchParams.get('page') ? searchParams.get('page') : '0', - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; - // get the docs - const docs = await incomesModel.find({}).skip(range[0]).limit(range[1]), + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; + // range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; + // get the docs (sorted by addedAt in descending order to show newest first) + const docs = await incomesModel.find({}).sort({ addedAt: -1 }).skip(range[0]).limit(range[1]), // get the size of the docs docs_count = await incomesModel.countDocuments({}); // prepare the return data @@ -90,14 +91,14 @@ export async function GET(req:Request) { // get the searchKeyword param let searchKeyword = searchParams.get('searchKeyword') - // get the search results docs + // get the search results docs (sorted by addedAt in descending order) let results = await incomesModel.find({ $or: [ // search by ( case insensitive search ) { name: { $regex: searchKeyword, $options: 'i' } }, { description: { $regex: searchKeyword, $options: 'i' } } ] - }) + }).sort({ addedAt: -1 }) // get the docs let responseData = { docs: results, @@ -136,7 +137,7 @@ export async function DELETE(req:Request) const { searchParams } = new URL(req.url) const page = searchParams.get('page'), _id : string | null = searchParams.get('_id'), - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // update the outcome // get the old amount let doc = await incomesModel.findById(_id) @@ -148,8 +149,8 @@ export async function DELETE(req:Request) }) // delete the doc await incomesModel.findByIdAndRemove(_id) - // get the docs by page - const docs = await incomesModel.find({}).skip(range[0]).limit(range[1]), + // get the docs by page (sorted by addedAt in descending order) + const docs = await incomesModel.find({}).sort({ addedAt: -1 }).skip(range[0]).limit(range[1]), // get the size of the docs docs_count = await incomesModel.countDocuments({}); // prepare the return data diff --git a/webapp/src/app/api/user/actions/members/route.ts b/webapp/src/app/api/user/actions/members/route.ts index d710bb9..cd924d4 100644 --- a/webapp/src/app/api/user/actions/members/route.ts +++ b/webapp/src/app/api/user/actions/members/route.ts @@ -20,9 +20,29 @@ export async function POST(req:Request) email : string | null = payload.email as string | null, phone : string | null = payload.phone as string | null, address : string | null = payload.address as string | null, - services : [string] | [] = payload.services as [string] | [], + // Parse and validate services + services : string[] = (() => { + try { + if (typeof payload.services === 'string') { + const parsed = JSON.parse(payload.services); + if (!Array.isArray(parsed)) { + throw new Error('Services must be an array'); + } + return parsed; + } else if (Array.isArray(payload.services)) { + return payload.services; + } else { + throw new Error('Services must be an array or stringified array'); + } + } catch (parseError) { + console.error('Error parsing services:', parseError); + console.error('Services value:', payload.services); + console.error('Services type:', typeof payload.services); + return []; + } + })(), planDelay : number = payload.planDelay ? payload.planDelay : 1 as number, - gendre : string | null = payload.gendre as string | null, + gendre : 'm' | 'w' = payload.gendre as 'm' | 'w', bodyState : { startBodyForm: String, startWeight: Number, @@ -30,38 +50,101 @@ export async function POST(req:Request) startBodyForm: String, startWeight: Number, } | null; - // calculat month pay + + // Validate required fields + if (!firstName || !lastName || !phone) { + return NextResponse.json({ + success: false, + message: "Missing required fields: firstName, lastName, and phone are required", + }, { + status: 400, + headers: { + "content-type": "application/json" + } + }) + } + + // Validate gendre field + if (gendre && gendre !== 'm' && gendre !== 'w') { + return NextResponse.json({ + success: false, + message: "Invalid gender value. Must be 'm' or 'w'", + }, { + status: 400, + headers: { + "content-type": "application/json" + } + }) + } + + // Validate services array + if (!services || services.length === 0) { + return NextResponse.json({ + success: false, + message: "At least one service must be selected", + }, { + status: 400, + headers: { + "content-type": "application/json" + } + }) + } + // Get service details and create per-service subscription data const ids = services?.map((v , i) => { return new mongoose.Types.ObjectId(v as any) }) - const servicesDocs = await serviceModel.aggregate([ - { - $match: { - _id: { $in : ids } + const servicesDocs = await serviceModel.find({ _id: { $in: ids } }) + + // Validate that all services exist + if (servicesDocs.length !== services.length) { + return NextResponse.json({ + success: false, + message: "One or more selected services do not exist", + }, { + status: 400, + headers: { + "content-type": "application/json" } - }, - { - $group: { - _id: null, - totalPrice: { - $sum: "$price" - } - } - } - ]) - // inc the value of totalSubscribers for all included services in the membership + }) + } + + // Calculate total monthly pay + const payMonth = servicesDocs.reduce((total, service) => total + service.price, 0) + + // Declare needed variables + let current_date = new Date(), + current_date_unix = Math.floor(Date.now() / 1000), + planDelay_unix = planDelay ? planDelay * 2592000: 0 * 2592000, + planExpAt = new Date((current_date_unix + planDelay_unix) * 1000); + + // Create services array with per-service subscription details + const servicesArray = servicesDocs.map(service => ({ + serviceID: service._id.toString(), + serviceName: service.name, + registeredAt: current_date.toUTCString(), + registeredAt_unix: current_date_unix, + planDelay: planDelay, + planDelay_unix: planDelay_unix, + planExpAt: planExpAt.toUTCString(), + planExpAt_unix: current_date_unix + planDelay_unix, + planUpdatedAt: current_date.toUTCString(), + planUpdatedAt_unix: current_date_unix, + active: true, + })) + + // Debug logging + // Debug: Log services parsing + console.log('Original services from payload:', payload.services); + console.log('Parsed services:', services); + + // Increment the value of totalSubscribers for all included services await serviceModel.updateMany({_id : {$in : ids}} , { $inc: { totalSubscribers: 1 } }) - const payMonth : number = servicesDocs[0] ? servicesDocs[0].totalPrice : 0 - // declare needed variables - let current_date = new Date(), - current_date_unix = Math.floor(Date.now() / 1000), - planDelay_unix = planDelay ? planDelay * 2592000: 0 * 2592000, - planExpAt = new Date((current_date_unix + planDelay_unix) * 1000); - // save the doc + + // Save the doc with new structure const doc = new memberModel({ firstName, lastName, @@ -69,11 +152,12 @@ export async function POST(req:Request) phone, address, payMonth, - services, + services: servicesArray, gendre, bodyState, registerAt: current_date.toUTCString(), registerAt_unix: current_date_unix, + // Global subscription status (derived from services) planUpdatedAt: current_date.toUTCString(), planUpdatedAt_unix: current_date_unix, planDelay, @@ -82,8 +166,36 @@ export async function POST(req:Request) planExpAt_unix: current_date_unix + planDelay_unix, active: true, }) + await doc.save() + // Create income entry for new member subscription + if (payMonth > 0) { + try { + const serviceNames = await serviceModel.find({ _id: { $in: ids } }, 'name'); + const serviceNamesList = serviceNames.map(service => service.name).join(', '); + const totalAmount = payMonth * planDelay; + + const incomeEntry = new incomeModel({ + name: `New Member Subscription - ${firstName} ${lastName}`, + description: `New member subscription for services: ${serviceNamesList} (${planDelay} month${planDelay > 1 ? 's' : ''})`, + amount: totalAmount, + addedAt: current_date_unix + }); + await incomeEntry.save(); + + // Update total income in statistics + await statisticsModel.findOneAndUpdate({}, { + $inc: { + totalIncome: totalAmount + } + }, { upsert: true }); + } catch (incomeError) { + console.error('Error creating income entry:', incomeError); + // Don't fail the member creation if income entry fails + } + } + // return the success response with the new added doc return NextResponse.json({ success: true, @@ -97,10 +209,14 @@ export async function POST(req:Request) }) }catch(e) { + // Log the actual error for debugging + console.error('Error adding new member:', e); + // catch any error and return an error response return NextResponse.json({ success: false, message: "serverError", + error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500, headers: { @@ -171,10 +287,14 @@ export async function GET(req:Request) } }catch(e) { + // Log the actual error for debugging + console.error('Error updating member:', e); + // catch any error and return an error response return NextResponse.json({ success: false, message: "serverError", + error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500, headers: { @@ -211,10 +331,14 @@ export async function DELETE(req:Request) }) }catch(e) { + // Log the actual error for debugging + console.error('Error in GET request:', e); + // catch any error and return an error response return NextResponse.json({ success: false, message: "serverError", + error: e instanceof Error ? e.message : 'Unknown error' }, { status: 500, headers: { @@ -242,7 +366,8 @@ export async function PUT(req:Request) email : string | null = payload.email as string | null, phone : string | null = payload.phone as string | null, address : string | null = payload.address as string | null, - active : boolean | null = payload.active as boolean | null; + active : boolean | null = payload.active as boolean | null, + gendre : 'm' | 'w' | null = payload.gendre as 'm' | 'w' | null; // save the doc const doc = await memberModel.findByIdAndUpdate( _id , { firstName, @@ -251,6 +376,7 @@ export async function PUT(req:Request) phone, address, active, + gendre, } , { new: true }) @@ -270,7 +396,18 @@ export async function PUT(req:Request) { // declare the needed variables const _id : string | null = payload._id as string | null, - services : [string] | [] = payload.services as [string] | [], + // Parse services if it's a string (handle both string and array cases) + newServices : string[] = (() => { + if (typeof payload.services === 'string') { + try { + return JSON.parse(payload.services); + } catch (parseError) { + console.error('Error parsing services JSON in PUT:', parseError); + return []; + } + } + return payload.services as string[]; + })(), planDelay : number = payload.planDelay ? payload.planDelay : 1 as number, bodyState : { currentBodyForm: String, @@ -279,53 +416,166 @@ export async function PUT(req:Request) currentBodyForm: String, currentWeight: Number, } | null; - // calculat month pay - const ids = services?.map((v , i) => { - return new mongoose.Types.ObjectId(v as string) - }) - const servicesDocs = await serviceModel.aggregate([ - { - $match: { - _id: { $in : ids } + + // Get current member data + const currentMember = await memberModel.findById(_id); + if (!currentMember) { + return NextResponse.json({ + success: false, + message: "Member not found", + }, { + status: 404, + headers: { + "content-type": "application/json" } - }, - { - $group: { - _id: null, - totalPrice: { - $sum: "$price" - } + }) + } + + // Get service details for new services + const newServiceIds = newServices.map(id => new mongoose.Types.ObjectId(id as string)); + const newServicesDocs = await serviceModel.find({ _id: { $in: newServiceIds } }); + + // Current time variables + const current_date = new Date(); + const current_date_unix = Math.floor(Date.now() / 1000); + const planDelay_unix = planDelay * 2592000; + + // Process current services and new services + const currentServices = currentMember.services || []; + const updatedServices = [...currentServices]; + let totalIncome = 0; + const incomeServiceNames = []; + + // Handle each new service + for (const newServiceDoc of newServicesDocs) { + const serviceId = newServiceDoc._id.toString(); + const existingServiceIndex = updatedServices.findIndex(s => s.serviceID === serviceId); + + if (existingServiceIndex >= 0) { + // Service exists - extend or replace based on current status + const existingService = updatedServices[existingServiceIndex]; + const isServiceActive = existingService.planExpAt_unix > current_date_unix; + + if (isServiceActive) { + // Extend active service + const newExpiration = existingService.planExpAt_unix + planDelay_unix; + updatedServices[existingServiceIndex] = { + serviceID: existingService.serviceID, + serviceName: existingService.serviceName, + registeredAt: existingService.registeredAt, + registeredAt_unix: existingService.registeredAt_unix, + planDelay: existingService.planDelay + planDelay, + planDelay_unix: existingService.planDelay_unix + planDelay_unix, + planExpAt: new Date(newExpiration * 1000).toUTCString(), + planExpAt_unix: newExpiration, + planUpdatedAt: current_date.toUTCString(), + planUpdatedAt_unix: current_date_unix, + active: true, + }; + // Charge for extension + totalIncome += newServiceDoc.price * planDelay; + incomeServiceNames.push(`${newServiceDoc.name} (extended)`); + } else { + // Replace expired service + const newExpiration = current_date_unix + planDelay_unix; + updatedServices[existingServiceIndex] = { + serviceID: existingService.serviceID, + serviceName: existingService.serviceName, + registeredAt: current_date.toUTCString(), + registeredAt_unix: current_date_unix, + planDelay: planDelay, + planDelay_unix: planDelay_unix, + planExpAt: new Date(newExpiration * 1000).toUTCString(), + planExpAt_unix: newExpiration, + planUpdatedAt: current_date.toUTCString(), + planUpdatedAt_unix: current_date_unix, + active: true, + }; + // Charge for replacement + totalIncome += newServiceDoc.price * planDelay; + incomeServiceNames.push(`${newServiceDoc.name} (renewed)`); } + } else { + // New service - add it + const newExpiration = current_date_unix + planDelay_unix; + updatedServices.push({ + serviceID: serviceId, + serviceName: newServiceDoc.name, + registeredAt: current_date.toUTCString(), + registeredAt_unix: current_date_unix, + planDelay: planDelay, + planDelay_unix: planDelay_unix, + planExpAt: new Date(newExpiration * 1000).toUTCString(), + planExpAt_unix: newExpiration, + planUpdatedAt: current_date.toUTCString(), + planUpdatedAt_unix: current_date_unix, + active: true, + }); + // Charge for new service + totalIncome += newServiceDoc.price * planDelay; + incomeServiceNames.push(`${newServiceDoc.name} (new)`); } - ]) - const payMonth : number = servicesDocs[0] ? servicesDocs[0].totalPrice : 0 - // declare needed variables - let current_date = new Date(), - current_date_unix = Math.floor(Date.now() / 1000), - planDelay_unix = planDelay ? planDelay * 2592000: 0 * 2592000, - planExpAt = new Date((current_date_unix + planDelay_unix) * 1000); - // save the doc - const doc = await memberModel.findByIdAndUpdate( _id , { + } + + // Calculate total monthly pay and global subscription status + const allServiceIds = updatedServices.map(s => new mongoose.Types.ObjectId(s.serviceID)); + const allServicesDocs = await serviceModel.find({ _id: { $in: allServiceIds } }); + const payMonth = allServicesDocs.reduce((total, service) => total + service.price, 0); + + // Determine global subscription status (latest expiration date) + const latestExpiration = Math.max(...updatedServices.map(s => s.planExpAt_unix)); + const globalActive = latestExpiration > current_date_unix; + const globalPlanDelay = Math.max(...updatedServices.map(s => s.planDelay)); + + // Convert services to plain objects to avoid Mongoose document nesting issues + const plainServices = updatedServices.map(service => ({ + serviceID: service.serviceID, + serviceName: service.serviceName, + registeredAt: service.registeredAt, + registeredAt_unix: service.registeredAt_unix, + planDelay: service.planDelay, + planDelay_unix: service.planDelay_unix, + planExpAt: service.planExpAt, + planExpAt_unix: service.planExpAt_unix, + planUpdatedAt: service.planUpdatedAt, + planUpdatedAt_unix: service.planUpdatedAt_unix, + active: service.active, + })); + + // Update member document + const doc = await memberModel.findByIdAndUpdate(_id, { bodyState, - services, - payMonth, + services: plainServices, + payMonth, planUpdatedAt: current_date.toUTCString(), planUpdatedAt_unix: current_date_unix, - planDelay, - planDelay_unix: planDelay_unix, - planExpAt: planExpAt.toUTCString(), - planExpAt_unix: current_date_unix + planDelay_unix, - active: true, - } , { + planDelay: globalPlanDelay, + planDelay_unix: globalPlanDelay * 2592000, + planExpAt: new Date(latestExpiration * 1000).toUTCString(), + planExpAt_unix: latestExpiration, + active: globalActive, + }, { new: true - }) - // add the subscription income - let income = payMonth * planDelay - await statisticsModel.findOneAndUpdate({} , { - $inc: { - totalIncome: income - } - }) + }); + + // Add income entry if there's any charge + if (totalIncome > 0) { + await statisticsModel.findOneAndUpdate({}, { + $inc: { + totalIncome: totalIncome + } + }); + + const member = await memberModel.findById(_id, 'firstName lastName'); + const incomeEntry = new incomeModel({ + name: `Subscription Update - ${member?.firstName} ${member?.lastName}`, + description: `Services updated: ${incomeServiceNames.join(', ')}`, + amount: totalIncome, + addedAt: current_date_unix + }); + await incomeEntry.save(); + } + // return the success response return NextResponse.json({ success: true, diff --git a/webapp/src/app/api/user/actions/products/route.ts b/webapp/src/app/api/user/actions/products/route.ts index 55bf855..0cad67d 100644 --- a/webapp/src/app/api/user/actions/products/route.ts +++ b/webapp/src/app/api/user/actions/products/route.ts @@ -65,7 +65,7 @@ export async function GET(req:Request) { // get the page const page : string | null = searchParams.get('page') ? searchParams.get('page') : '0', - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // get the docs const docs = await productsModel.find({}).skip(range[0]).limit(range[1]), // get the size of the docs @@ -131,7 +131,7 @@ export async function DELETE(req:Request) const { searchParams } = new URL(req.url) const page = searchParams.get('page'), _id : string | null = searchParams.get('_id'), - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // delete the doc await productsModel.findByIdAndRemove(_id) // get the docs by page diff --git a/webapp/src/app/api/user/actions/services-subscriptions/route.ts b/webapp/src/app/api/user/actions/services-subscriptions/route.ts new file mode 100644 index 0000000..053f7dc --- /dev/null +++ b/webapp/src/app/api/user/actions/services-subscriptions/route.ts @@ -0,0 +1,105 @@ +import dbConnect from "@/database/dbConnect"; +import { NextResponse } from "next/server"; +import memberModel from "@/database/models/memberModel"; +import serviceModel from "@/database/models/serviceModel"; +import { Types } from 'mongoose'; + +// set the revalidate variable +export const revalidate = 5; + +/** + * GET METHOD - Fetch services with their active subscription counts + * This endpoint calculates real-time active subscriptions for each service + * by counting members who have active subscriptions to each service + */ +export async function GET(req: Request) { + try { + await dbConnect(); + + const currentUnix = Math.floor(new Date().getTime() / 1000); + + // Get all services + const services = await serviceModel.find({}, { _id: 1, name: 1, status: 1 }); + + // Calculate active subscriptions for each service + const servicesWithSubscriptions = await Promise.all( + services.map(async (service) => { + // Count members with active subscriptions to this specific service + const activeSubscriptionsCount = await memberModel.countDocuments({ + 'services': { + $elemMatch: { + 'serviceID': service._id.toString(), + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } + }); + + // Count total subscriptions (including expired) for this service + const totalSubscriptionsCount = await memberModel.countDocuments({ + 'services': { + $elemMatch: { + 'serviceID': service._id.toString() + } + } + }); + + return { + _id: service._id, + name: service.name, + status: service.status, + activeSubscriptions: activeSubscriptionsCount, + totalSubscriptions: totalSubscriptionsCount, + expiredSubscriptions: totalSubscriptionsCount - activeSubscriptionsCount + }; + }) + ); + + // Calculate summary statistics + const totalActiveSubscriptions = servicesWithSubscriptions.reduce( + (sum, service) => sum + service.activeSubscriptions, 0 + ); + const totalExpiredSubscriptions = servicesWithSubscriptions.reduce( + (sum, service) => sum + service.expiredSubscriptions, 0 + ); + const totalSubscriptions = servicesWithSubscriptions.reduce( + (sum, service) => sum + service.totalSubscriptions, 0 + ); + + // Prepare response data + const responseData = { + services: servicesWithSubscriptions, + summary: { + totalServices: services.length, + activeServices: services.filter(s => s.status === 'active').length, + inactiveServices: services.filter(s => s.status === 'inactive').length, + totalActiveSubscriptions, + totalExpiredSubscriptions, + totalSubscriptions + } + }; + + return NextResponse.json({ + success: true, + message: "Services subscriptions data retrieved successfully", + data: responseData, + }, { + status: 200, + headers: { + "content-type": "application/json" + } + }); + + } catch (error) { + console.error("Error fetching services subscriptions data:", error); + return NextResponse.json({ + success: false, + message: "Server error", + }, { + status: 500, + headers: { + "content-type": "application/json" + } + }); + } +} \ No newline at end of file diff --git a/webapp/src/app/api/user/actions/services/route.ts b/webapp/src/app/api/user/actions/services/route.ts index e0efe6e..7b89715 100644 --- a/webapp/src/app/api/user/actions/services/route.ts +++ b/webapp/src/app/api/user/actions/services/route.ts @@ -64,9 +64,10 @@ export async function GET(req:Request) { // get the page const page : string | null = searchParams.get('page') ? searchParams.get('page') : '0', - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; + // get the docs - const docs = await serviceModel.find({}).skip(range[0]).limit(range[1]), + const docs = await serviceModel.find({}).sort({ addedAt: -1 }).skip(range[0]).limit(range[1]), // get the size of the docs docs_count = await serviceModel.countDocuments({}); // prepare the return data @@ -91,7 +92,7 @@ export async function GET(req:Request) { name: { $regex: searchKeyword, $options: 'i' } }, { description: { $regex: searchKeyword, $options: 'i' } } ] - }) + }).sort({ addedAt: -1 }) // get the docs let responseData = { docs: results, @@ -130,11 +131,11 @@ export async function DELETE(req:Request) const { searchParams } = new URL(req.url) const page = searchParams.get('page'), _id : string | null = searchParams.get('_id'), - range : number[] = [(parseInt(page?page : '0') - 1) * 4 , 4]; + range : number[] = [(parseInt(page?page : '0') - 1) * 10, 10]; // delete the doc await serviceModel.findByIdAndRemove(_id) // get the docs by page - const docs = await serviceModel.find({}).skip(range[0]).limit(range[1]), + const docs = await serviceModel.find({}).sort({ addedAt: -1 }).skip(range[0]).limit(range[1]), // get the size of the docs docs_count = await serviceModel.countDocuments({}); // prepare the return data diff --git a/webapp/src/app/api/user/actions/statistics/route.ts b/webapp/src/app/api/user/actions/statistics/route.ts index d433e8d..f22b2cf 100644 --- a/webapp/src/app/api/user/actions/statistics/route.ts +++ b/webapp/src/app/api/user/actions/statistics/route.ts @@ -58,15 +58,34 @@ async function updateMembersOverviewStatistics() { registerAt_unix: { $lt: endOfDayUnix } }); - const totalActiveSubs = await memberModel.countDocuments({ - planExpAt_unix: { $gt: currentUnix }, - registerAt_unix: { $lt: endOfDayUnix } - }); + // Count members with at least one active service subscription + const totalActiveSubs = await memberModel.countDocuments({ + registerAt_unix: { $lt: endOfDayUnix }, + 'services': { + $elemMatch: { + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } + }); - const totalUnActiveSubs = await memberModel.countDocuments({ - planExpAt_unix: { $lte: currentUnix }, - registerAt_unix: { $lt: endOfDayUnix } - }); + // Count members with no active service subscriptions + const totalUnActiveSubs = await memberModel.countDocuments({ + registerAt_unix: { $lt: endOfDayUnix }, + $or: [ + { 'services': { $size: 0 } }, + { + 'services': { + $not: { + $elemMatch: { + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } + } + } + ] + }); const totalMansMembers = await memberModel.countDocuments({ gendre: 'm', @@ -135,13 +154,33 @@ async function updateMembersOverviewStatistics() { const totalMembers = await memberModel.countDocuments({ registerAt_unix: { $lt: endOfDayUnix } }); + // Count members with at least one active service subscription const totalActiveSubs = await memberModel.countDocuments({ - planExpAt_unix: { $gt: currentUnix }, - registerAt_unix: { $lt: endOfDayUnix } + registerAt_unix: { $lt: endOfDayUnix }, + 'services': { + $elemMatch: { + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } }); + + // Count members with no active service subscriptions const totalUnActiveSubs = await memberModel.countDocuments({ - planExpAt_unix: { $lte: currentUnix }, - registerAt_unix: { $lt: endOfDayUnix } + registerAt_unix: { $lt: endOfDayUnix }, + $or: [ + { 'services': { $size: 0 } }, + { + 'services': { + $not: { + $elemMatch: { + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } + } + } + ] }); const totalMansMembers = await memberModel.countDocuments({ gendre: 'm', @@ -236,13 +275,33 @@ async function updateMembersOverviewStatistics() { const totalMembers = await memberModel.countDocuments({ registerAt_unix: { $lt: endOfDayUnix } }); + // Count members with at least one active service subscription const totalActiveSubs = await memberModel.countDocuments({ - planExpAt_unix: { $gt: currentUnix }, - registerAt_unix: { $lt: endOfDayUnix } + registerAt_unix: { $lt: endOfDayUnix }, + 'services': { + $elemMatch: { + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } }); + + // Count members with no active service subscriptions const totalUnActiveSubs = await memberModel.countDocuments({ - planExpAt_unix: { $lte: currentUnix }, - registerAt_unix: { $lt: endOfDayUnix } + registerAt_unix: { $lt: endOfDayUnix }, + $or: [ + { 'services': { $size: 0 } }, + { + 'services': { + $not: { + $elemMatch: { + 'planExpAt_unix': { $gt: currentUnix }, + 'active': true + } + } + } + } + ] }); const totalMansMembers = await memberModel.countDocuments({ gendre: 'm', @@ -365,12 +424,48 @@ export async function GET(req:Request) let membersCount : number = await memberModel.count({}) let activeMembersCount : number = await memberModel.count({active: true}) let disActiveMembersCount : number = await memberModel.count({active: false}) - let activeSubscriptionsCount : number = await memberModel.count({planExpAt_unix: {$gt : Math.floor(new Date().getTime() / 1000)}}) - let expiredSoonSubscriptionsCount : number = await memberModel.count({planExpAt_unix: {$gt : Math.floor(new Date().getTime() / 1000) + 259200}}) - let expiredSubscriptionsCount : number = await memberModel.count({planExpAt_unix: {$lte : Math.floor(new Date().getTime() / 1000)}}) + // Count members with at least one active service subscription + let activeSubscriptionsCount : number = await memberModel.count({ + 'services': { + $elemMatch: { + 'planExpAt_unix': { $gt: Math.floor(new Date().getTime() / 1000) }, + 'active': true + } + } + }); + + // Count members with subscriptions expiring soon (within 3 days) + let expiredSoonSubscriptionsCount : number = await memberModel.count({ + 'services': { + $elemMatch: { + 'planExpAt_unix': { + $gt: Math.floor(new Date().getTime() / 1000), + $lte: Math.floor(new Date().getTime() / 1000) + 259200 + }, + 'active': true + } + } + }); + + // Count members with no active service subscriptions + let expiredSubscriptionsCount : number = await memberModel.count({ + $or: [ + { 'services': { $size: 0 } }, + { + 'services': { + $not: { + $elemMatch: { + 'planExpAt_unix': { $gt: Math.floor(new Date().getTime() / 1000) }, + 'active': true + } + } + } + } + ] + }); let servicesCount : number = await servicesModel.count({}) let activeServicesCount : number = await servicesModel.count({status: "active"}) - let disActiveServicesCount : number = await servicesModel.count({status: "active"}) + let disActiveServicesCount : number = await servicesModel.count({status: "inactive"}) let servicesNameAndSubscribers : { _id: Types.ObjectId, diff --git a/webapp/src/components/dashboard/expenses/expensesList.tsx b/webapp/src/components/dashboard/expenses/expensesList.tsx index 1ee2bdc..5f11eb5 100644 --- a/webapp/src/components/dashboard/expenses/expensesList.tsx +++ b/webapp/src/components/dashboard/expenses/expensesList.tsx @@ -36,12 +36,12 @@ export default function List() let dateObj = new Date(unix_time * 1000) // we got the year let year = dateObj.getUTCFullYear(), - // get month - month = dateObj.getUTCMonth(), - // get day - day = dateObj.getUTCDay(); - // now we format the string - let formattedTime = year.toString()+ '-' + month.toString() + '-' + day.toString(); + // get month (add 1 because getUTCMonth returns 0-11) + month = dateObj.getUTCMonth() + 1, + // get day of month + day = dateObj.getUTCDate(); + // now we format the string with zero padding + let formattedTime = year.toString()+ '-' + month.toString().padStart(2, '0') + '-' + day.toString().padStart(2, '0'); return formattedTime; } diff --git a/webapp/src/components/dashboard/home/incomeOutcome.tsx b/webapp/src/components/dashboard/home/incomeOutcome.tsx index 658d6fe..8175b6f 100644 --- a/webapp/src/components/dashboard/home/incomeOutcome.tsx +++ b/webapp/src/components/dashboard/home/incomeOutcome.tsx @@ -2,65 +2,164 @@ import { useAppSelector } from '@/redux/store'; import { useTranslations } from 'next-intl'; import dynamic from 'next/dynamic'; import Cookies from 'universal-cookie'; +import { useState, useEffect } from 'react'; const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false, }); +interface AnalyticsData { + view: 'monthly' | 'yearly'; + year?: number; + months?: Array<{ + month: number; + monthName: string; + income: number; + expense: number; + net: number; + }>; + years?: Array<{ + year: number; + income: number; + expense: number; + net: number; + }>; + totalIncome: number; + totalExpense: number; + totalNet: number; +} + export default function IncomeOutcome() { // get needed redux state const report = useAppSelector((state) => state.statisticsReducer.value.report) const currencySymbol = useAppSelector((state) => state.settingsReducer.value.appGeneralSettings.currencySymbol) ?? '' const themeType = useAppSelector((state) => state.themeTypeReducer.value.themeType) + // declare global variables const cookies = new Cookies(); const t = useTranslations('statistics'); const locale = cookies.get("NEXT_LOCALE") const isRtl = locale == 'ar' ? true : false - const isDark = themeType == 'dark' ? true : false + const isDark = themeType == 'dark' ? true : false + + // state for analytics data and view mode + const [analyticsData, setAnalyticsData] = useState(null); + const [viewMode, setViewMode] = useState<'monthly' | 'yearly'>('monthly'); + const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); + const [loading, setLoading] = useState(true); + + // fetch analytics data + useEffect(() => { + const fetchAnalyticsData = async () => { + try { + setLoading(true); + const response = await fetch(`/api/user/actions/income-expense-analytics?view=${viewMode}&year=${selectedYear}`); + const result = await response.json(); + + if (result.success) { + setAnalyticsData(result.data); + } + } catch (error) { + console.error('Error fetching analytics data:', error); + } finally { + setLoading(false); + } + }; + + fetchAnalyticsData(); + }, [viewMode, selectedYear]); + // if loading show load screen - if(!report) return null // Return null or a loading indicator if report is not yet available - // prepare chart data - // Ensure totalIncome and totalOutcome are numbers, default to 0 if undefined/null - let data = [report?.totalIncome ?? 0 , report?.totalOutcome ?? 0] - // prepare chart labels - let labels = [t('income') , t('outcome')] + if (loading || !analyticsData) { + return ( +
+

{t('incomeOutcomeGeneralOverview')}

+
+
{t('loading')}
+
+
+ ); + } + + // prepare chart data based on view mode + let data: number[] = []; + let labels: string[] = []; + let categories: string[] = []; + + if (viewMode === 'monthly' && analyticsData.months) { + data = analyticsData.months.flatMap(month => [month.income, month.expense]); + labels = [t('income'), t('outcome')]; + categories = analyticsData.months.flatMap(month => [month.monthName, month.monthName]); + } else if (viewMode === 'yearly' && analyticsData.years) { + data = analyticsData.years.flatMap(year => [year.income, year.expense]); + labels = [t('income'), t('outcome')]; + categories = analyticsData.years.flatMap(year => [year.year.toString(), year.year.toString()]); + } + + // prepare series data for grouped chart + const incomeData = viewMode === 'monthly' + ? analyticsData.months?.map(month => month.income) || [] + : analyticsData.years?.map(year => year.income) || []; + const expenseData = viewMode === 'monthly' + ? analyticsData.months?.map(month => month.expense) || [] + : analyticsData.years?.map(year => year.expense) || []; + const chartCategories = viewMode === 'monthly' + ? analyticsData.months?.map(month => month.monthName) || [] + : analyticsData.years?.map(year => year.year.toString()) || []; // prepare chart options var options = { - series: [{ - name: currencySymbol, - data: data - }], - annotations: { - points: [{ - x: t('incomes'), - seriesIndex: 0, - label: { - borderColor: '#775DD0', - offsetY: 0, - style: { - color: '#fff', - background: '#775DD0', - }, + series: [ + { + name: t('income'), + data: incomeData, + color: '#0263FF' + }, + { + name: t('outcome'), + data: expenseData, + color: '#E3342F' } - }] - }, + ], chart: { height: 350, type: 'bar', + toolbar: { + show: true, + tools: { + download: true, + selection: false, + zoom: false, + zoomin: false, + zoomout: false, + pan: false, + reset: false + } + } }, plotOptions: { bar: { - borderRadius: 10, - columnWidth: '50%', - horizontal: true, - distributed: true, + borderRadius: 4, + columnWidth: '60%', + dataLabels: { + position: 'top' + } } - }, + }, + dataLabels: { + enabled: true, + formatter: function (val: number) { + return currencySymbol + val.toLocaleString(); + }, + offsetY: -20, + style: { + fontSize: '12px', + colors: [isDark ? '#fff' : '#000'] + } + }, legend: { position: 'top', horizontalAlign: 'right', - fontSize: '16px', + fontSize: '14px', markers: { width: 10, height: 10, @@ -71,67 +170,140 @@ export default function IncomeOutcome() vertical: 5, }, }, - fill: { - type: isDark ? '' : 'gradient', - gradient: isDark ? {} : { - shadeIntensity: 1, - inverseColors: !1, - opacityFrom: isDark ? 0.19 : 0.28, - opacityTo: 0.05, - stops: isDark ? [100, 100] : [45, 100], - }, - }, - colors: ['#0263FF' , '#E3342F'], - dataLabels: { - enabled: false - }, + colors: ['#0263FF', '#E3342F'], stroke: { width: 2, curve: 'straight', }, grid: { - row: { - colors: [isDark ? '#333' : '#fff', '#f2f2f2'] // Adjust the grid row color for dark mode - }, + borderColor: isDark ? '#333' : '#e0e0e0', + strokeDashArray: 3, }, - xaxis: { + xaxis: { + categories: chartCategories, labels: { - rotate: -45, + rotate: viewMode === 'monthly' ? -45 : 0, offsetX: isRtl ? 2 : 0, offsetY: 5, style: { colors: isDark ? '#fff' : '#000', fontSize: '12px', - cssClass: 'apexcharts-xaxis-title', }, }, - categories: labels, - tickPlacement: 'on' + axisBorder: { + color: isDark ? '#333' : '#e0e0e0' + }, + axisTicks: { + color: isDark ? '#333' : '#e0e0e0' + } }, yaxis: { - tickAmount: 7, labels: { - offsetX: isRtl ? -30 : -10, + offsetX: isRtl ? -10 : -10, offsetY: 0, style: { colors: isDark ? '#fff' : '#000', fontSize: '12px', - cssClass: 'apexcharts-yaxis-title', }, + formatter: function (val: number) { + return currencySymbol + val.toLocaleString(); + } }, }, tooltip: { theme: isDark ? 'dark' : 'light', - x: { - show: true, - }, + shared: true, + intersect: false, + y: { + formatter: function (val: number) { + return currencySymbol + val.toLocaleString(); + } + } }, }; // return the ui return ( <>
-

{t('incomeOutcomeGeneralOverview')}

+ {/* Header with controls */} +
+

{t('incomeOutcomeGeneralOverview')}

+ +
+ {/* View Mode Toggle */} +
+ + +
+ + {/* Year Selector for Monthly View */} + {viewMode === 'monthly' && ( + + )} +
+
+ + {/* Summary Cards */} +
+
+
{t('totalIncome')}
+
+ {currencySymbol}{analyticsData.totalIncome.toLocaleString()} +
+
+
+
{t('totalExpense')}
+
+ {currencySymbol}{analyticsData.totalExpense.toLocaleString()} +
+
+
= 0 + ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' + : 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' + }`}> +
= 0 + ? 'text-green-600 dark:text-green-400' + : 'text-red-600 dark:text-red-400' + }`}>{t('netProfit')}
+
= 0 + ? 'text-green-700 dark:text-green-300' + : 'text-red-700 dark:text-red-300' + }`}> + {currencySymbol}{analyticsData.totalNet.toLocaleString()} +
+
+
+ + {/* Chart */}
diff --git a/webapp/src/components/dashboard/home/servicesSubscriptions.tsx b/webapp/src/components/dashboard/home/servicesSubscriptions.tsx index 41f9eeb..eef93f5 100644 --- a/webapp/src/components/dashboard/home/servicesSubscriptions.tsx +++ b/webapp/src/components/dashboard/home/servicesSubscriptions.tsx @@ -1,147 +1,317 @@ -import { useAppSelector } from '@/redux/store'; +import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import dynamic from 'next/dynamic'; import Cookies from 'universal-cookie'; +import { useAppSelector } from '@/redux/store'; + const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false, }); -export default function ServicesSubscriptions() -{ - // get redux needed state - const report = useAppSelector((state) => state.statisticsReducer.value.report) - const themeType = useAppSelector((state) => state.themeTypeReducer.value.themeType) - // declare global variables +interface ServiceSubscriptionData { + _id: string; + name: string; + status: string; + activeSubscriptions: number; + totalSubscriptions: number; + expiredSubscriptions: number; +} + +interface ServicesSubscriptionsResponse { + services: ServiceSubscriptionData[]; + summary: { + totalServices: number; + activeServices: number; + inactiveServices: number; + totalActiveSubscriptions: number; + totalExpiredSubscriptions: number; + totalSubscriptions: number; + }; +} + +export default function ServicesSubscriptions() { + const [servicesData, setServicesData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Get theme state from Redux + const themeType = useAppSelector((state) => state.themeTypeReducer.value.themeType); + + // Declare global variables const cookies = new Cookies(); const t = useTranslations('statistics'); - const locale = cookies.get("NEXT_LOCALE") - const isRtl = locale == 'ar' ? true : false - const isDark = themeType == 'dark' ? true : false - // if loading show load screen - if(!report) return - // setup chart data - let data = report?.servicesNameAndSubscribers.value.map((v : { - _id: string, - name: string, - totalSubscribers: number, - } , i : number) => { - return v?.totalSubscribers; - }) - // setup chart labels - let labels = report?.servicesNameAndSubscribers.value.map((v : { - _id: string, - name: string, - totalSubscribers: number, - } , i : number) => { - return v?.name; - }) - // setup chart options - var options = { - series: [{ - name: t('subscription'), - data: data - }], - annotations: { - points: [{ - x: t('services'), - seriesIndex: 0, - label: { - borderColor: '#775DD0', - offsetY: 0, - style: { - color: '#fff', - background: '#775DD0', - }, + const locale = cookies.get("NEXT_LOCALE"); + const isRtl = locale === 'ar'; + const isDark = themeType === 'dark'; + + // Fetch services subscriptions data + useEffect(() => { + const fetchServicesData = async () => { + try { + setLoading(true); + const response = await fetch('/api/user/actions/services-subscriptions'); + const result = await response.json(); + + if (result.success) { + setServicesData(result.data); + setError(null); + } else { + setError(result.message || 'Failed to fetch data'); + } + } catch (err) { + setError('Network error occurred'); + console.error('Error fetching services data:', err); + } finally { + setLoading(false); } - }] - }, + }; + + fetchServicesData(); + }, []); + + // Show loading state + if (loading) { + return ( +
+

{t('sevicesGeneralOverview')}

+
+
+
+

{t('loading')}

+
+
+
+ ); + } + + // Show error state + if (error || !servicesData) { + return ( +
+

{t('sevicesGeneralOverview')}

+
+
+

{error || 'No data available'}

+
+
+
+ ); + } + + // Prepare chart data + const activeData = servicesData.services.map(service => service.activeSubscriptions); + const expiredData = servicesData.services.map(service => service.expiredSubscriptions); + const labels = servicesData.services.map(service => service.name); + + // Setup chart options + const options = { + series: [ + { + name: t('activeSubscriptions'), + data: activeData + }, + { + name: t('expiredSubscriptions'), + data: expiredData + } + ], chart: { height: 350, - type: 'bar', + type: 'bar' as const, + stacked: true, + toolbar: { + show: true, + tools: { + download: true, + selection: false, + zoom: false, + zoomin: false, + zoomout: false, + pan: false, + reset: false + } + } }, plotOptions: { bar: { - borderRadius: 10, - columnWidth: '50%', - distributed: true, + borderRadius: 8, + columnWidth: '60%', + horizontal: false, } - }, + }, fill: { - type: isDark ? '' : 'gradient', + type: isDark ? 'solid' : 'gradient', gradient: isDark ? {} : { shadeIntensity: 1, - inverseColors: !1, - opacityFrom: isDark ? 0.19 : 0.28, - opacityTo: 0.05, - stops: isDark ? [100, 100] : [45, 100], + inverseColors: false, + opacityFrom: 0.85, + opacityTo: 0.55, + stops: [0, 100], }, - }, + }, legend: { - position: 'top', - horizontalAlign: 'right', - fontSize: '16px', + position: 'top' as const, + horizontalAlign: 'right' as const, + fontSize: '14px', markers: { - width: 10, - height: 10, + width: 12, + height: 12, offsetX: isRtl ? 5 : -5, }, itemMargin: { horizontal: 10, vertical: 5, }, + labels: { + colors: isDark ? '#fff' : '#000', + } }, - colors: ['#38C172', '#38C172' , '#E3342F' , '#0263FF', '#FF30F7'], + colors: ['#10B981', '#EF4444'], // Green for active, Red for expired dataLabels: { - enabled: false + enabled: true, + style: { + colors: ['#fff'] + }, + formatter: function(val: number) { + return val > 0 ? val.toString() : ''; + } }, stroke: { - width: 2, - curve: 'straight', + width: 1, + colors: ['transparent'] }, grid: { - row: { - colors: [isDark ? '#333' : '#fff', '#f2f2f2'] // Adjust the grid row color for dark mode - }, + borderColor: isDark ? '#374151' : '#E5E7EB', + strokeDashArray: 3, }, - xaxis: { + xaxis: { + categories: labels, labels: { rotate: -45, offsetX: isRtl ? 2 : 0, offsetY: 5, style: { - colors: isDark ? '#fff' : '#000', + colors: isDark ? '#D1D5DB' : '#374151', fontSize: '12px', - cssClass: 'apexcharts-xaxis-title', }, + maxHeight: 80, }, - categories: labels, + axisBorder: { + color: isDark ? '#374151' : '#E5E7EB', + }, + axisTicks: { + color: isDark ? '#374151' : '#E5E7EB', + } }, yaxis: { - tickAmount: 7, labels: { - offsetX: isRtl ? -30 : -10, - offsetY: 0, + offsetX: isRtl ? -10 : -5, style: { - colors: isDark ? '#fff' : '#000', + colors: isDark ? '#D1D5DB' : '#374151', fontSize: '12px', - cssClass: 'apexcharts-yaxis-title', }, }, + title: { + text: t('subscriptionsCount'), + style: { + color: isDark ? '#D1D5DB' : '#374151', + fontSize: '14px', + fontWeight: 600, + } + } }, tooltip: { theme: isDark ? 'dark' : 'light', - x: { - show: true, - }, + shared: true, + intersect: false, + y: { + formatter: function(val: number, opts: any) { + const seriesName = opts.seriesIndex === 0 ? + t('activeSubscriptions') : + t('expiredSubscriptions'); + return `${seriesName}: ${val}`; + } + } }, + responsive: [ + { + breakpoint: 768, + options: { + chart: { + height: 300 + }, + plotOptions: { + bar: { + columnWidth: '80%' + } + } + } + } + ] }; - // return the ui + return ( - <> -
+
+ {/* Header */} +

{t('sevicesGeneralOverview')}

- + + {/* Summary Stats */} +
+
+

+ {t('totalServices')} +

+

+ {servicesData.summary.totalServices} +

+
+ +
+

+ {t('activeSubscriptions')} +

+

+ {servicesData.summary.totalActiveSubscriptions} +

+
+ +
+

+ {t('expiredSubscriptions')} +

+

+ {servicesData.summary.totalExpiredSubscriptions} +

+
+ +
+

+ {t('totalSubscriptions')} +

+

+ {servicesData.summary.totalSubscriptions} +

+
+
- - ) + + {/* Chart */} +
+ {servicesData.services.length > 0 ? ( + + ) : ( +
+

{t('noServicesFound')}

+
+ )} +
+
+ ); } \ No newline at end of file diff --git a/webapp/src/components/dashboard/incomes/incomesList.tsx b/webapp/src/components/dashboard/incomes/incomesList.tsx index b11334f..7b3b144 100644 --- a/webapp/src/components/dashboard/incomes/incomesList.tsx +++ b/webapp/src/components/dashboard/incomes/incomesList.tsx @@ -36,12 +36,12 @@ export default function List() let dateObj = new Date(unix_time * 1000) // we got the year let year = dateObj.getUTCFullYear(), - // get month - month = dateObj.getUTCMonth(), - // get day - day = dateObj.getUTCDay(); - // now we format the string - let formattedTime = year.toString()+ '-' + month.toString() + '-' + day.toString(); + // get month (add 1 because getUTCMonth returns 0-11) + month = dateObj.getUTCMonth() + 1, + // get day of month + day = dateObj.getUTCDate(); + // now we format the string with zero padding + let formattedTime = year.toString()+ '-' + month.toString().padStart(2, '0') + '-' + day.toString().padStart(2, '0'); return formattedTime; } diff --git a/webapp/src/components/dashboard/incomes/parts/addNewButton.tsx b/webapp/src/components/dashboard/incomes/parts/addNewButton.tsx index f07bc4f..978c465 100644 --- a/webapp/src/components/dashboard/incomes/parts/addNewButton.tsx +++ b/webapp/src/components/dashboard/incomes/parts/addNewButton.tsx @@ -22,8 +22,8 @@ export default function AddNewButton() flex justify-between items-center gap-3 "> - - + +

{t('addNewIncome')}

diff --git a/webapp/src/components/dashboard/members/membersList.tsx b/webapp/src/components/dashboard/members/membersList.tsx index 9abca25..8a0d995 100644 --- a/webapp/src/components/dashboard/members/membersList.tsx +++ b/webapp/src/components/dashboard/members/membersList.tsx @@ -59,8 +59,8 @@ export default function List() {t('firstName')} {t('lastName')} {t('gendre')} - {t('payMonth')} - {t('planDelay')} + {t('phone')} + {t('activeServices')} {t('planStatus')} {t('actions')} @@ -122,34 +122,52 @@ export default function List() {v.firstName} {v.lastName} {t(v.gendre+'Gendre')} - {v.payMonth} {appGeneralSettings.currencySymbol} - {v.planDelay + ' ' + t('months')} + {v.phone} + {v.services ? v.services.length : 0} - {v.planExpAt_unix - (Date.now() / 1000) > 259200 ? - - - - - {t('trueActive')} - - : - ( - v.planExpAt_unix - (Date.now() / 1000) > 0 ? - - - - - {t('expireVerySoon')} - - : - - - - - {t('falseActive')} - - ) - } + {(() => { + // Calculate overall subscription status based on individual services + const currentTime = Date.now() / 1000; + const activeServices = v.services?.filter(service => + service.active && service.planExpAt_unix > currentTime + ) || []; + + if (activeServices.length === 0) { + return ( + + + + + {t('falseActive')} + + ); + } + + // Check if any service expires soon (within 3 days) + const expiringSoon = activeServices.some(service => + service.planExpAt_unix - currentTime <= 259200 + ); + + if (expiringSoon) { + return ( + + + + + {t('expireVerySoon')} + + ); + } + + return ( + + + + + {t('trueActive')} + + ); + })()}
- t('search')} isMulti styles={customStyles} placeholder={t("services")} diff --git a/webapp/src/components/dashboard/members/parts/detailsPopUp.tsx b/webapp/src/components/dashboard/members/parts/detailsPopUp.tsx index ec86c2e..ca901cd 100644 --- a/webapp/src/components/dashboard/members/parts/detailsPopUp.tsx +++ b/webapp/src/components/dashboard/members/parts/detailsPopUp.tsx @@ -281,13 +281,96 @@ export default function ServiceDetailsPopUp()

+ {/* Services Subscription Section */} +
+

{t('memberServices')}

+
+ {detailsPopUpData?.services && detailsPopUpData.services.length > 0 ? ( +
+
+ {detailsPopUpData.services.map((service: any, index: number) => { + const currentTime = Date.now() / 1000; + const isActive = service.planExpAt_unix > currentTime; + const isExpiringSoon = service.planExpAt_unix - currentTime <= 259200 && service.planExpAt_unix > currentTime; + + return ( +
+
+

{service.serviceName}

+
+
+ {t('planStatus')}: + + {isActive && !isExpiringSoon ? + + + + + {t('trueActive')} + + : + ( + isExpiringSoon ? + + + + + {t('expireVerySoon')} + + : + + + + + {t('falseActive')} + + ) + } + +
+
+ {t('registeredAt')}: + {new Date(service.registeredAt).toLocaleDateString()} +
+
+ {t('planDelay')}: + {service.planDelay} {t('months')} +
+
+ {t('planExpAt')}: + {new Date(service.planExpAt).toLocaleDateString()} +
+
+ {t('planUpdatedAt')}: + {new Date(service.planUpdatedAt).toLocaleDateString()} +
+
+
+
+ ); + })} +
+
+

+ {t('note')}: {t('perServiceSubscriptionNote')} +

+
+
+ ) : ( +
+

{t('noServicesSubscribed')}

+
+ )} +
+
+ {/* QR Code Section */}
-

{t('memberQRCode') || 'Member QR Code'}

+

{t('memberQRCode')}

- {t('qrCodeDescription') || 'Scan this QR code to quickly access member information'} + {t('qrCodeDescription')}

- {t('downloadQR') || 'Download QR Code'} + {t('downloadQR')}

- {t('qrDownloadNote') || 'Downloads as PNG image'} + {t('qrDownloadNote')}

diff --git a/webapp/src/components/dashboard/members/parts/updatePopUp.tsx b/webapp/src/components/dashboard/members/parts/updatePopUp.tsx index 4761415..7428aff 100644 --- a/webapp/src/components/dashboard/members/parts/updatePopUp.tsx +++ b/webapp/src/components/dashboard/members/parts/updatePopUp.tsx @@ -85,7 +85,7 @@ export default function UpdatePopUp() lastName : updatePopUpData?.lastName ? updatePopUpData?.lastName : '' , email : updatePopUpData?.email ? updatePopUpData?.email : '' , phone : updatePopUpData?.phone ? updatePopUpData?.phone : '' , - //gendre: updatePopUpData?.gendre ? updatePopUpData?.gendre : 'm' , + gendre: updatePopUpData?.gendre ? updatePopUpData?.gendre : 'm' , address : updatePopUpData?.address ? updatePopUpData?.address : '' , active: updatePopUpData?.active, //payMonth: updatePopUpData?.payMonth ? updatePopUpData?.payMonth : 0, @@ -163,6 +163,41 @@ export default function UpdatePopUp() placeholder={t('lastName')} />
+
+

{t('gender')}

+
+
+ + +
+
+ + +
+
+

{t('status')}

(); // intl-18 const t = useTranslations('members'); // mui + const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('md')); // get state from redux @@ -48,6 +50,7 @@ export default function UpdateSubPopUp() status: false, data: null })); + router.refresh(); }; // setup multi select // no options react select component @@ -126,25 +129,28 @@ export default function UpdateSubPopUp() }), }; function ServicesMultiSelect ({setFieldValue , errors , touched} : {setFieldValue : any , errors : any , touched : any}) { - // load services - const listContent = useAppSelector((state) => state.servicesReducer.value.listContent) - async function loadServices (searchKeyword : string) { - if(searchKeyword) - { - await dispatch(searchForServices({searchKeyword: searchKeyword})) + const [servicesOptions, setServicesOptions] = useState([]); + + useEffect(() => { + async function loadServices() { + try { + let docs = await loadAllServices(); + let options = docs.map((v, i) => { + return {value: v._id, label: v.name} + }); + setServicesOptions(options); + } catch (error) { + console.error('Error loading services:', error); + } } - let options = listContent.map((v , i) => { - return {value: v._id , label: v.name} - }) - return options - } + loadServices(); + }, []); + return (
- t('search')} isMulti styles={customStyles} placeholder={t("services")} @@ -218,6 +224,7 @@ export default function UpdateSubPopUp() await dispatch(updateSub(values)) // close the popup handleClose(); + }} > {({ @@ -245,6 +252,67 @@ export default function UpdateSubPopUp()

{t('memberSubscriptionRenewalText1')}

+ + {/* Current Services Subscription Status */} + {updateSubPopUpData && updateSubPopUpData.services && ( +
+

{t('currentServicesStatus')}

+ + {updateSubPopUpData.services.length > 0 ? ( +
+ {updateSubPopUpData.services.map((service, index) => { + const currentTime = Math.floor(Date.now() / 1000); + const isActive = service.planExpAt_unix && service.planExpAt_unix > currentTime; + + return ( +
+
+
+

{t('serviceName')}:

+

{service.serviceName}

+
+
+

{t('subscriptionStatus')}:

+

+ {isActive + ? {t('active')} + : {t('expired')} + } +

+
+
+

{t('expiresAt')}:

+

{service.planExpAt || t('notSet')}

+
+
+

{t('registeredAt')}:

+

{service.registeredAt || t('notSet')}

+
+
+

{t('planDelay')}:

+

{service.planDelay || 0} {t('months')}

+
+
+

{t('lastUpdated')}:

+

{service.planUpdatedAt || t('notSet')}

+
+
+
+ ); + })} +
+ ) : ( +

{t('noServicesSubscribed')}

+ )} + + {/* Explanation of update behavior */} +
+

+ {t('note')}: {t('subscriptionUpdateNote')} +

+
+
+ )}
-