From 4f3b56784e76df9c7b64dcfc6e999e7597bc1583 Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Sat, 18 Jul 2026 21:32:47 -0300 Subject: [PATCH] feat: implement sysadmin subscription management and plan usage cycle recalculation tools --- .../src/Models/Appointments/Appointments.ts | 29 ++- .../src/Models/Metrics/Metrics.Interface.ts | 4 + server/src/Models/Metrics/Metrics.ts | 52 +++- .../PlanUsageCycle.Adapter.Mongoose.ts | 155 +++++++++++ .../PlanUsageCycle.Interface.ts | 94 +++++++ .../PlanUsageCycle/PlanUsageCycle.helpers.ts | 20 ++ .../Models/PlanUsageCycle/PlanUsageCycle.ts | 102 ++++++++ .../Models/Plans/Plans.Adapter.Mongoose.ts | 6 +- server/src/Models/Plans/Plans.interface.ts | 29 +++ server/src/Models/Plans/Plans.ts | 8 +- server/src/Models/Users/Users.Interface.ts | 55 ++++ server/src/api/Dashboard/Dashboard.Service.ts | 10 +- .../SysAdminPlanUsageCycle.Controller.ts | 29 +++ .../SysAdminPlanUsageCycle.Service.ts | 49 ++++ .../Plans/SysAdminPlans.Controller.ts | 44 ++++ .../SysAdmin/Plans/SysAdminPlans.Service.ts | 83 ++++++ .../SysAdminSubscriptions.Controller.ts | 48 ++++ .../SysAdminSubscriptions.Service.ts | 147 +++++++++++ sysadmin-cli/src/App.tsx | 2 + sysadmin-cli/src/api/sysadmin.ts | 113 +++++++++ .../src/components/layout/DashboardLayout.tsx | 6 +- .../src/components/ui/EditPlanModal.tsx | 159 ++++++++++++ .../components/ui/RecalculationToolsModal.tsx | 109 ++++++++ .../ui/SubscriptionDetailsModal.tsx | 240 ++++++++++++++++++ sysadmin-cli/src/pages/PlansPage.tsx | 181 +++++++++++++ sysadmin-cli/src/pages/UsersPage.tsx | 122 ++++++++- sysadmin-cli/src/utils/formatters.ts | 18 ++ sysadmin/src/app.ts | 2 + sysadmin/src/controllers/plans.controller.ts | 28 ++ sysadmin/src/controllers/users.controller.ts | 36 +++ sysadmin/src/models/Plans.Model.ts | 31 +++ sysadmin/src/models/Users.Model.ts | 64 +++++ sysadmin/src/routes/plans.routes.ts | 10 + sysadmin/src/routes/users.routes.ts | 3 + sysadmin/src/services/plans.service.ts | 31 +++ sysadmin/src/services/users.service.ts | 48 ++++ 36 files changed, 2146 insertions(+), 21 deletions(-) create mode 100644 server/src/Models/PlanUsageCycle/PlanUsageCycle.Adapter.Mongoose.ts create mode 100644 server/src/Models/PlanUsageCycle/PlanUsageCycle.Interface.ts create mode 100644 server/src/Models/PlanUsageCycle/PlanUsageCycle.helpers.ts create mode 100644 server/src/Models/PlanUsageCycle/PlanUsageCycle.ts create mode 100644 server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Controller.ts create mode 100644 server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Service.ts create mode 100644 server/src/api/SysAdmin/Plans/SysAdminPlans.Controller.ts create mode 100644 server/src/api/SysAdmin/Plans/SysAdminPlans.Service.ts create mode 100644 server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts create mode 100644 server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts create mode 100644 sysadmin-cli/src/components/ui/EditPlanModal.tsx create mode 100644 sysadmin-cli/src/components/ui/RecalculationToolsModal.tsx create mode 100644 sysadmin-cli/src/components/ui/SubscriptionDetailsModal.tsx create mode 100644 sysadmin-cli/src/pages/PlansPage.tsx create mode 100644 sysadmin-cli/src/utils/formatters.ts create mode 100644 sysadmin/src/controllers/plans.controller.ts create mode 100644 sysadmin/src/models/Plans.Model.ts create mode 100644 sysadmin/src/routes/plans.routes.ts create mode 100644 sysadmin/src/services/plans.service.ts diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index dd19986..8a09421 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -305,12 +305,6 @@ class AppointmentManager implements IAppointmentsManager { throw new Error("La compañia no existe"); } - const canAdd = await MetricsList.canAddAppointment(companyCheck.ownerId); - - if (!canAdd) { - throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan."); - } - const serviceCheck = await ServicesManager.services.findOne({ _id: data.serviceId, companyId: data.companyId, @@ -443,6 +437,18 @@ class AppointmentManager implements IAppointmentsManager { data.comments = isNull(data.comments, ""); + const appointmentReserved = await MetricsList.reserveAppointment({ + userId: String(companyCheck.ownerId), + companyId: data.companyId, + quantity: 1, + }); + + if (!appointmentReserved) { + throw new Error("Ha alcanzado el limite de turnos permitidos de acuerdo a su plan."); + } + + let appointmentCreated = false; + try { const start = dayjs(data.start).clone(); const end = dayjs(data.start) @@ -467,6 +473,7 @@ class AppointmentManager implements IAppointmentsManager { comments: data.comments, sessionUser: data.sessionUser, }); + appointmentCreated = true; if (data.repeatId) { const repeat = await RepeatsList.repeats.findOne({ @@ -489,8 +496,9 @@ class AppointmentManager implements IAppointmentsManager { } } - await MetricsList.addAppointment({ + await MetricsList.metrics.addAppointment({ userId: companyCheck.ownerId, + companyId: data.companyId, quantity: 1, }); @@ -559,6 +567,13 @@ class AppointmentManager implements IAppointmentsManager { return newAppointment; } catch (err) { console.log(err); + if (!appointmentCreated) { + await MetricsList.releaseAppointment({ + userId: String(companyCheck.ownerId), + companyId: data.companyId, + quantity: 1, + }); + } throw new Error("Ha ocurrido un error al crear el turno."); } } diff --git a/server/src/Models/Metrics/Metrics.Interface.ts b/server/src/Models/Metrics/Metrics.Interface.ts index 2e252e3..067c957 100644 --- a/server/src/Models/Metrics/Metrics.Interface.ts +++ b/server/src/Models/Metrics/Metrics.Interface.ts @@ -2,7 +2,9 @@ import { IMetricsDocument } from "./Metrics.Adapter.Mongoose"; export type MetricsParams = { userId?: string; + companyId?: string; quantity?: number; + limit?: number; }; export type DeleteMetricsByCompany = { @@ -45,6 +47,8 @@ export interface IMetricsManager { addEmployee(data: MetricsParams): Promise; addService(data: MetricsParams): Promise; addAppointment(data: MetricsParams): Promise; + reserveAppointment(data: MetricsParams): Promise; + releaseAppointment(data: MetricsParams): Promise; addClient(data: MetricsParams): Promise; canAddOrganization(userId: string): Promise; canAddEmployee(userId: string): Promise; diff --git a/server/src/Models/Metrics/Metrics.ts b/server/src/Models/Metrics/Metrics.ts index a25b9e8..1f0c52c 100644 --- a/server/src/Models/Metrics/Metrics.ts +++ b/server/src/Models/Metrics/Metrics.ts @@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments"; import ClientsList from "../Clients/Clients"; import RepeatsList from "../Repeats/Repeats"; import { isNull } from "../../helpers/IsNull"; +import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle"; class MetricsManager implements IMetricsManager { metrics: MetricsAdapterMongoose; @@ -231,17 +232,64 @@ class MetricsManager implements IMetricsManager { if (plan.limitAppointments < 0) { return true; } - const metrics = await this.getMetrics(userId); - if (metrics.appointmentsCount >= plan.limitAppointments) { + const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId }); + if (appointmentsCount >= plan.limitAppointments) { return false; } return true; } public async addAppointment(data: MetricsParams): Promise { + if (data.userId) { + await PlanUsageCycleList.addAppointment({ + userId: data.userId, + quantity: data.quantity, + }); + } await this.metrics.addAppointment(data); } + public async reserveAppointment(data: MetricsParams): Promise { + if (!data.userId) { + return false; + } + + const subscription = await PlanSubscriptionsList.findOne({ + sessionUser: data.userId, + }); + if (!subscription) { + return false; + } + const plan = await PlansList.plans.findOne({ + _id: subscription.planId, + }); + if (!plan) { + return false; + } + if (plan.limitAppointments < 0) { + await PlanUsageCycleList.addAppointment({ + userId: data.userId, + quantity: data.quantity, + }); + return true; + } + + return await PlanUsageCycleList.reserveAppointment({ + userId: data.userId, + quantity: data.quantity, + limit: plan.limitAppointments, + }); + } + + public async releaseAppointment(data: MetricsParams): Promise { + if (data.userId) { + await PlanUsageCycleList.releaseAppointment({ + userId: data.userId, + quantity: data.quantity, + }); + } + } + public async addClient(data: MetricsParams): Promise { await this.metrics.addClient(data); } diff --git a/server/src/Models/PlanUsageCycle/PlanUsageCycle.Adapter.Mongoose.ts b/server/src/Models/PlanUsageCycle/PlanUsageCycle.Adapter.Mongoose.ts new file mode 100644 index 0000000..8acd838 --- /dev/null +++ b/server/src/Models/PlanUsageCycle/PlanUsageCycle.Adapter.Mongoose.ts @@ -0,0 +1,155 @@ +import { Document, Model, Schema, model } from "mongoose"; +import { + CreatePlanUsageCycleParams, + FindPlanUsageCycleParams, + IPlanUsageCycle, + IPlanUsageCycleAdapter, + UpdatePlanUsageCycleParams, +} from "./PlanUsageCycle.Interface"; + +export interface IPlanUsageCycleDocument extends Omit, Document {} + +export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter { + schema: Schema; + planUsageCycleList: Model; + + constructor() { + this.schema = new Schema({ + userId: { type: Schema.Types.ObjectId, required: true, ref: "User" }, + subscriptionId: { type: Schema.Types.ObjectId, required: false, ref: "PlanSuscription" }, + planId: { type: Schema.Types.ObjectId, required: true, ref: "Plan" }, + cycleStart: { type: Date, required: true }, + cycleEnd: { type: Date, required: true }, + appointmentsCount: { type: Number, required: true, default: 0 }, + creationDate: { type: Date, required: true, default: Date.now }, + updateDate: { type: Date, required: true, default: Date.now }, + }); + + this.schema.index({ userId: 1, subscriptionId: 1, planId: 1, cycleStart: 1 }, { unique: true }); + + this.planUsageCycleList = model("PlanUsageCycle", this.schema); + } + + public async findOne(filters: FindPlanUsageCycleParams): Promise { + return await this.planUsageCycleList.findOne(filters); + } + + public async create(data: CreatePlanUsageCycleParams): Promise { + return await this.planUsageCycleList.create({ + ...data, + appointmentsCount: data.appointmentsCount || 0, + updateDate: new Date(), + }); + } + + public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise { + const now = new Date(); + return await this.planUsageCycleList.findOneAndUpdate( + { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + }, + { + $setOnInsert: { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + cycleEnd: data.cycleEnd, + appointmentsCount: 0, + creationDate: now, + }, + $set: { updateDate: now }, + }, + { new: true, upsert: true } + ); + } + + public async incrementAppointments( + data: UpdatePlanUsageCycleParams & { quantity: number } + ): Promise { + const now = new Date(); + return await this.planUsageCycleList.findOneAndUpdate( + { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + }, + { + $setOnInsert: { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + cycleEnd: data.cycleEnd, + appointmentsCount: 0, + creationDate: now, + }, + $inc: { appointmentsCount: data.quantity }, + $set: { updateDate: now }, + }, + { new: true, upsert: true } + ); + } + + public async incrementAppointmentsIfWithinLimit( + data: UpdatePlanUsageCycleParams & { quantity: number; limit: number } + ): Promise { + const now = new Date(); + return await this.planUsageCycleList.findOneAndUpdate( + { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + appointmentsCount: { $lte: data.limit - data.quantity }, + }, + { + $setOnInsert: { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + cycleEnd: data.cycleEnd, + appointmentsCount: 0, + creationDate: now, + }, + $inc: { appointmentsCount: data.quantity }, + $set: { updateDate: now }, + }, + { new: true } + ); + } + + public async setAppointmentsCount( + data: UpdatePlanUsageCycleParams & { appointmentsCount: number } + ): Promise { + const now = new Date(); + return await this.planUsageCycleList.findOneAndUpdate( + { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + }, + { + $setOnInsert: { + userId: data.userId, + subscriptionId: data.subscriptionId, + planId: data.planId, + cycleStart: data.cycleStart, + cycleEnd: data.cycleEnd, + creationDate: now, + }, + $set: { + appointmentsCount: Math.max(data.appointmentsCount, 0), + updateDate: now, + }, + }, + { new: true, upsert: true } + ); + } +} diff --git a/server/src/Models/PlanUsageCycle/PlanUsageCycle.Interface.ts b/server/src/Models/PlanUsageCycle/PlanUsageCycle.Interface.ts new file mode 100644 index 0000000..9640f59 --- /dev/null +++ b/server/src/Models/PlanUsageCycle/PlanUsageCycle.Interface.ts @@ -0,0 +1,94 @@ +import { IPlanUsageCycleDocument } from "./PlanUsageCycle.Adapter.Mongoose"; + +export interface IPlanUsageCycle { + id?: string; + userId: string; + subscriptionId?: string; + planId: string; + cycleStart: Date; + cycleEnd: Date; + appointmentsCount: number; + creationDate: Date; + updateDate: Date; +} + +export type FindPlanUsageCycleParams = { + userId?: string; + subscriptionId?: string; + planId?: string; + cycleStart?: Date; + cycleEnd?: Date; +}; + +export type CreatePlanUsageCycleParams = { + userId: string; + subscriptionId?: string; + planId: string; + cycleStart: Date; + cycleEnd: Date; + appointmentsCount?: number; +}; + +export type CurrentPlanUsageCycleParams = { + userId: string; + now?: Date; + limit?: number; +}; + +export type SetPlanUsageCycleAppointmentsParams = CurrentPlanUsageCycleParams & { + appointmentsCount: number; +}; + +export type SysAdminRecalculatePlanUsageCycleParams = { + userId: string; +}; + +export type SysAdminRecalculatePlanUsageCycleResult = { + userId: string; + subscriptionId: string; + planId: string; + cycleStart: Date; + cycleEnd: Date; + appointmentsCount: number; +}; + +export type UpdatePlanUsageCycleParams = { + userId: string; + subscriptionId: string; + planId: string; + cycleStart: Date; + cycleEnd: Date; +}; + +export type CalculateCurrentCycleParams = { + subscriptionStartDate: Date; + now?: Date; +}; + +export type PlanUsageCycleRange = { + cycleStart: Date; + cycleEnd: Date; +}; + +export interface IPlanUsageCycleAdapter { + findOne(filters: FindPlanUsageCycleParams): Promise; + create(data: CreatePlanUsageCycleParams): Promise; + getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise; + incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise; + incrementAppointmentsIfWithinLimit( + data: UpdatePlanUsageCycleParams & { quantity: number; limit: number } + ): Promise; + setAppointmentsCount( + data: UpdatePlanUsageCycleParams & { appointmentsCount: number } + ): Promise; +} + +export interface IPlanUsageCycleManager { + planUsageCycle: IPlanUsageCycleAdapter; + getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise; + addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise; + reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise; + releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise; + getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise; + setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise; +} diff --git a/server/src/Models/PlanUsageCycle/PlanUsageCycle.helpers.ts b/server/src/Models/PlanUsageCycle/PlanUsageCycle.helpers.ts new file mode 100644 index 0000000..078d1d8 --- /dev/null +++ b/server/src/Models/PlanUsageCycle/PlanUsageCycle.helpers.ts @@ -0,0 +1,20 @@ +import dayjs from "dayjs"; +import { CalculateCurrentCycleParams, PlanUsageCycleRange } from "./PlanUsageCycle.Interface"; + +export function calculateCurrentPlanUsageCycle(data: CalculateCurrentCycleParams): PlanUsageCycleRange { + const now = dayjs(data.now || new Date()); + const anchor = dayjs(data.subscriptionStartDate); + + let cycleStart = anchor.clone(); + let cycleEnd = cycleStart.clone().add(1, "month"); + + while (!cycleEnd.isAfter(now)) { + cycleStart = cycleEnd; + cycleEnd = cycleStart.clone().add(1, "month"); + } + + return { + cycleStart: cycleStart.toDate(), + cycleEnd: cycleEnd.toDate(), + }; +} diff --git a/server/src/Models/PlanUsageCycle/PlanUsageCycle.ts b/server/src/Models/PlanUsageCycle/PlanUsageCycle.ts new file mode 100644 index 0000000..c536060 --- /dev/null +++ b/server/src/Models/PlanUsageCycle/PlanUsageCycle.ts @@ -0,0 +1,102 @@ +import { isNull } from "../../helpers/IsNull"; +import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons"; +import { + CurrentPlanUsageCycleParams, + IPlanUsageCycleManager, + SetPlanUsageCycleAppointmentsParams, + UpdatePlanUsageCycleParams, +} from "./PlanUsageCycle.Interface"; +import { + IPlanUsageCycleDocument, + PlanUsageCycleAdapterMongoose, +} from "./PlanUsageCycle.Adapter.Mongoose"; +import { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers"; + +export { calculateCurrentPlanUsageCycle } from "./PlanUsageCycle.helpers"; + +class PlanUsageCycleManager implements IPlanUsageCycleManager { + planUsageCycle: PlanUsageCycleAdapterMongoose; + + constructor() { + this.planUsageCycle = new PlanUsageCycleAdapterMongoose(); + } + + private async getCurrentCycleData(data: CurrentPlanUsageCycleParams): Promise { + const subscription = await PlanSubscriptionsList.findOne({ + sessionUser: data.userId, + }); + + if (!subscription) { + throw new Error("No se ha encontrado una suscripción activa"); + } + + const { cycleStart, cycleEnd } = calculateCurrentPlanUsageCycle({ + subscriptionStartDate: subscription.startDate, + now: data.now, + }); + + return { + userId: data.userId, + subscriptionId: String(subscription.id), + planId: String(subscription.planId), + cycleStart, + cycleEnd, + }; + } + + public async getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise { + return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data)); + } + + public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise { + await this.planUsageCycle.incrementAppointments({ + ...(await this.getCurrentCycleData(data)), + quantity: isNull(data.quantity, 1), + }); + } + + public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise { + if (typeof data.limit !== "number") { + return false; + } + + const quantity = isNull(data.quantity, 1); + const usageCycleData = await this.getCurrentCycleData(data); + const usageCycle = await this.planUsageCycle.getOrCreateCurrent(usageCycleData); + + if (usageCycle.appointmentsCount + quantity > data.limit) { + return false; + } + + const updatedCycle = await this.planUsageCycle.incrementAppointmentsIfWithinLimit({ + ...usageCycleData, + quantity, + limit: data.limit, + }); + + return !!updatedCycle; + } + + public async releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise { + await this.addAppointment({ + ...data, + quantity: -Math.abs(isNull(data.quantity, 1)), + }); + } + + public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise { + const usageCycle = await this.getCurrentCycle(data); + return usageCycle.appointmentsCount; + } + + public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise { + return await this.planUsageCycle.setAppointmentsCount({ + ...(await this.getCurrentCycleData(data)), + appointmentsCount: data.appointmentsCount, + }); + } +} + +const PlanUsageCycleList = new PlanUsageCycleManager(); + +export default PlanUsageCycleList; diff --git a/server/src/Models/Plans/Plans.Adapter.Mongoose.ts b/server/src/Models/Plans/Plans.Adapter.Mongoose.ts index baa14d3..cbe05d2 100644 --- a/server/src/Models/Plans/Plans.Adapter.Mongoose.ts +++ b/server/src/Models/Plans/Plans.Adapter.Mongoose.ts @@ -1,4 +1,4 @@ -import { FindPlansParams, IPlansAdapter, IPlan } from "./Plans.interface"; +import { FindPlansParams, IPlansAdapter, IPlan, SysAdminUpdatePlanParams } from "./Plans.interface"; import { Document, Model, Schema, model } from "mongoose"; export interface IPlanDocument extends Omit, Document {} @@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter { public async findOne(filters: FindPlansParams): Promise { return this.planList.findOne(filters).exec(); } + + public async updateOne(planId: string, data: Omit): Promise { + await this.planList.updateOne({ _id: planId }, { $set: data }).exec(); + } } diff --git a/server/src/Models/Plans/Plans.interface.ts b/server/src/Models/Plans/Plans.interface.ts index 597dcd1..e71d27f 100644 --- a/server/src/Models/Plans/Plans.interface.ts +++ b/server/src/Models/Plans/Plans.interface.ts @@ -12,6 +12,33 @@ export type FindPlansParams = { price?: number; }; +export type SysAdminUpdatePlanParams = { + planId: string; + name?: string; + description?: string; + features?: string[]; + code?: string; + price?: number; + annualPrice?: number; + limitOrganizations?: number; + limitEmployees?: number; + limitServices?: number; + limitAppointments?: number; + limitClients?: number; + limitRepeats?: number; + mailNotifications?: boolean; + smsNotifications?: boolean; + wapNotifications?: boolean; + payments?: boolean; + bot?: boolean; + active?: boolean; + dateLimit?: boolean; + discount3Months?: number; + discount6Months?: number; + discount12Months?: number; + featured?: boolean; +}; + export interface IPlan { id?: string; name: string; @@ -44,10 +71,12 @@ export interface IPlan { export interface IPlansAdapter { find(filters: FindPlansParams): Promise; findOne(filters: FindPlansParams): Promise; + updateOne(planId: string, data: Omit): Promise; } export interface IPlansManager { plans: IPlansAdapter; find(data: FindPlansParams): Promise; findOne(data: FindPlansParams): Promise; + sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise; } diff --git a/server/src/Models/Plans/Plans.ts b/server/src/Models/Plans/Plans.ts index e1e901a..586224a 100644 --- a/server/src/Models/Plans/Plans.ts +++ b/server/src/Models/Plans/Plans.ts @@ -1,6 +1,6 @@ import { isNull } from "../../helpers/IsNull"; import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose"; -import { FindPlansParams, IPlan, IPlansManager } from "./Plans.interface"; +import { FindPlansParams, IPlan, IPlansManager, SysAdminUpdatePlanParams } from "./Plans.interface"; class PlansManager implements IPlansManager { plans: PlansAdapterMongoose; @@ -59,6 +59,12 @@ class PlansManager implements IPlansManager { return this.getOnePlan(plan); } + + public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise { + const { planId, ...planData } = data; + await this.findOne({ _id: planId }); + await this.plans.updateOne(planId, planData); + } } const PlansList = new PlansManager(); diff --git a/server/src/Models/Users/Users.Interface.ts b/server/src/Models/Users/Users.Interface.ts index 5d7006e..30f05a5 100644 --- a/server/src/Models/Users/Users.Interface.ts +++ b/server/src/Models/Users/Users.Interface.ts @@ -205,6 +205,61 @@ export type SysAdminOrganizationsStatusParams = { userId: string; }; +export type SysAdminUserSubscriptionDetailsParams = { + userId: string; +}; + +export type SysAdminExtendUserSubscriptionMode = "add_months" | "set_end_date"; + +export type SysAdminExtendUserSubscriptionParams = { + userId: string; + mode: SysAdminExtendUserSubscriptionMode; + months?: 1 | 2; + endDate?: string; + reason?: string; +}; + +export type SysAdminExtendUserSubscriptionResult = { + userId: string; + subscriptionId: string; + previousEndDate: Date; + newEndDate: Date; + paymentId: string; + transactionId?: string; +}; + +export type SysAdminUserSubscriptionPayment = { + id: string; + subscriptionId: string; + amount: number; + paymentDate: Date; + paymentMethod?: string; + status: string; + transactionId?: string; +}; + +export type SysAdminUserSubscriptionDetailsResult = { + userId: string; + currentSubscription: { + id: string; + plan: { + id: string; + name: string; + code: string; + price: number; + } | null; + startDate: Date; + endDate: Date; + isActive: boolean; + mpStatus?: string; + billingMonths?: number; + pendingPaymentType?: "extension" | "upgrade"; + pendingPaymentPreferenceId?: string; + lastPaymentStatus?: string; + } | null; + payments: SysAdminUserSubscriptionPayment[]; +}; + export type SysAdminUserOrganizationsResult = { clientIn: { companyId: string; companyName: string }[]; collaboratorIn: { companyId: string; companyName: string }[]; diff --git a/server/src/api/Dashboard/Dashboard.Service.ts b/server/src/api/Dashboard/Dashboard.Service.ts index 0da3578..b7622c7 100644 --- a/server/src/api/Dashboard/Dashboard.Service.ts +++ b/server/src/api/Dashboard/Dashboard.Service.ts @@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow"; import CompaniesList from "../../Models/Companies/Companies"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import MetricsList from "../../Models/Metrics/Metrics"; +import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle"; export class DashboardService { public async getDashboardSummary(sessionUser: string): Promise { @@ -198,11 +199,18 @@ export class DashboardService { const isOwner = String(ownerId) === String(params.sessionUser); // Fetch metrics for owner - const metrics = await MetricsList.getMetrics(String(ownerId)); + const metricsDocument = await MetricsList.getMetrics(String(ownerId)); + const metrics = metricsDocument.toObject ? metricsDocument.toObject() : { ...metricsDocument }; // Fetch subscription for owner const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) }); + if (subscription) { + metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ + userId: String(ownerId), + }); + } + return { metrics, subscription, diff --git a/server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Controller.ts b/server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Controller.ts new file mode 100644 index 0000000..1972906 --- /dev/null +++ b/server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { ApiValidationError } from "../../../Models/Server.Error.model"; +import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth"; +import { + SysAdminRecalculatePlanUsageCycleParams, + SysAdminRecalculatePlanUsageCycleResult, +} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface"; +import { SysAdminPlanUsageCycleService } from "./SysAdminPlanUsageCycle.Service"; + +@Route("sysadmin/plan-usage-cycle") +@Middlewares(sysAdminAuthMiddleware) +export class SysAdminPlanUsageCycleController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post("recalculate-user") + public async recalculateUserUsageCycle( + @Body() requestBody: SysAdminRecalculatePlanUsageCycleParams + ): Promise { + try { + const result = await new SysAdminPlanUsageCycleService().recalculateUserUsageCycle(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Service.ts b/server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Service.ts new file mode 100644 index 0000000..016b03f --- /dev/null +++ b/server/src/api/SysAdmin/PlanUsageCycle/SysAdminPlanUsageCycle.Service.ts @@ -0,0 +1,49 @@ +import { connect } from "mongoose"; +import dayjs from "dayjs"; +import AppointmentList from "../../../Models/Appointments/Appointments"; +import CompaniesList from "../../../Models/Companies/Companies"; +import PlanUsageCycleList from "../../../Models/PlanUsageCycle/PlanUsageCycle"; +import { + SysAdminRecalculatePlanUsageCycleParams, + SysAdminRecalculatePlanUsageCycleResult, +} from "../../../Models/PlanUsageCycle/PlanUsageCycle.Interface"; + +export class SysAdminPlanUsageCycleService { + public async recalculateUserUsageCycle( + params: SysAdminRecalculatePlanUsageCycleParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + + const usageCycle = await PlanUsageCycleList.getCurrentCycle({ userId: params.userId }); + const companies = await CompaniesList.companies.find({ ownerId: params.userId }); + const companyIds = companies.map((company) => String(company.id)); + + const appointmentsCount = companyIds.length === 0 + ? 0 + : await AppointmentList.Appointments.AppointmentList.countDocuments({ + companyId: { $in: companyIds }, + creationDate: { + $gte: dayjs(usageCycle.cycleStart).toDate(), + $lt: dayjs(usageCycle.cycleEnd).toDate(), + }, + }); + + const updatedCycle = await PlanUsageCycleList.setAppointmentsCount({ + userId: params.userId, + appointmentsCount, + }); + + if (!updatedCycle) { + throw new Error("No se pudo recalcular el uso del plan."); + } + + return { + userId: params.userId, + subscriptionId: String(updatedCycle.subscriptionId), + planId: String(updatedCycle.planId), + cycleStart: updatedCycle.cycleStart, + cycleEnd: updatedCycle.cycleEnd, + appointmentsCount: updatedCycle.appointmentsCount, + }; + } +} diff --git a/server/src/api/SysAdmin/Plans/SysAdminPlans.Controller.ts b/server/src/api/SysAdmin/Plans/SysAdminPlans.Controller.ts new file mode 100644 index 0000000..aa69862 --- /dev/null +++ b/server/src/api/SysAdmin/Plans/SysAdminPlans.Controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; + +import { ApiValidationError } from "../../../Models/Server.Error.model"; +import { ApiVoidResult } from "../../../Models/Api.VoidResult.type"; +import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth"; +import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface"; +import { SysAdminPlansService } from "./SysAdminPlans.Service"; + +@Route("sysadmin/plans") +@Middlewares(sysAdminAuthMiddleware) +export class SysAdminPlansController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post("list") + public async listPlans(): Promise { + try { + const plans = await new SysAdminPlansService().listPlans(); + this.setStatus(200); + return plans; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } + + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "El plan fue modificado con éxito") + @Post("update") + public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise { + try { + await new SysAdminPlansService().updatePlan(requestBody); + this.setStatus(200); + return { + success: true, + message: "Done", + }; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/SysAdmin/Plans/SysAdminPlans.Service.ts b/server/src/api/SysAdmin/Plans/SysAdminPlans.Service.ts new file mode 100644 index 0000000..64ca1a9 --- /dev/null +++ b/server/src/api/SysAdmin/Plans/SysAdminPlans.Service.ts @@ -0,0 +1,83 @@ +import PlansList from "../../../Models/Plans/Plans"; +import { IPlan, SysAdminUpdatePlanParams } from "../../../Models/Plans/Plans.interface"; +import { connect } from "mongoose"; + +export class SysAdminPlansService { + public async listPlans(): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await PlansList.find({}); + } + + public async updatePlan(data: SysAdminUpdatePlanParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + await PlansList.sysAdminUpdatePlan(this.sanitizeUpdate(data)); + } + + private sanitizeUpdate(data: SysAdminUpdatePlanParams): SysAdminUpdatePlanParams { + if (!data.planId) { + throw new Error("Plan id is required"); + } + + const sanitized: SysAdminUpdatePlanParams = { planId: data.planId }; + const stringFields: (keyof SysAdminUpdatePlanParams)[] = ["name", "description", "code"]; + const nonNegativeNumberFields: (keyof SysAdminUpdatePlanParams)[] = [ + "price", + "annualPrice", + "discount3Months", + "discount6Months", + "discount12Months", + ]; + const limitFields: (keyof SysAdminUpdatePlanParams)[] = [ + "limitOrganizations", + "limitEmployees", + "limitServices", + "limitAppointments", + "limitClients", + "limitRepeats", + ]; + const booleanFields: (keyof SysAdminUpdatePlanParams)[] = [ + "mailNotifications", + "smsNotifications", + "wapNotifications", + "payments", + "bot", + "active", + "dateLimit", + "featured", + ]; + + for (const field of stringFields) { + const value = data[field]; + if (typeof value === "string") { + sanitized[field] = value.trim() as never; + } + } + + for (const field of nonNegativeNumberFields) { + const value = data[field]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + sanitized[field] = value as never; + } + } + + for (const field of limitFields) { + const value = data[field]; + if (typeof value === "number" && Number.isFinite(value)) { + sanitized[field] = value as never; + } + } + + for (const field of booleanFields) { + const value = data[field]; + if (typeof value === "boolean") { + sanitized[field] = value as never; + } + } + + if (Array.isArray(data.features)) { + sanitized.features = data.features.filter((feature) => typeof feature === "string").map((feature) => feature.trim()).filter(Boolean); + } + + return sanitized; + } +} diff --git a/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts new file mode 100644 index 0000000..9a0052f --- /dev/null +++ b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { ApiValidationError } from "../../../Models/Server.Error.model"; +import { sysAdminAuthMiddleware } from "../../../middleware/sysadminAuth"; +import { + SysAdminExtendUserSubscriptionParams, + SysAdminExtendUserSubscriptionResult, + SysAdminUserSubscriptionDetailsParams, + SysAdminUserSubscriptionDetailsResult, +} from "../../../Models/Users/Users.Interface"; +import { SysAdminSubscriptionsService } from "./SysAdminSubscriptions.Service"; + +@Route("sysadmin/subscriptions") +@Middlewares(sysAdminAuthMiddleware) +export class SysAdminSubscriptionsController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post("extend-user") + public async extendUserSubscription( + @Body() requestBody: SysAdminExtendUserSubscriptionParams + ): Promise { + try { + const result = await new SysAdminSubscriptionsService().extendUserSubscription(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } + + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post("user-details") + public async getUserSubscriptionDetails( + @Body() requestBody: SysAdminUserSubscriptionDetailsParams + ): Promise { + try { + const result = await new SysAdminSubscriptionsService().getUserSubscriptionDetails(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts new file mode 100644 index 0000000..2e5bb82 --- /dev/null +++ b/server/src/api/SysAdmin/Subscriptions/SysAdminSubscriptions.Service.ts @@ -0,0 +1,147 @@ +import { connect, isValidObjectId } from "mongoose"; +import dayjs from "dayjs"; +import PlanSubscriptionsList from "../../../Models/PlanSubscriptions/PlanSubscriptons"; +import PlanPaymentsList from "../../../Models/PlanPayments/PlanPayments"; +import PlansList from "../../../Models/Plans/Plans"; +import { + SysAdminExtendUserSubscriptionParams, + SysAdminExtendUserSubscriptionResult, + SysAdminUserSubscriptionDetailsParams, + SysAdminUserSubscriptionDetailsResult, +} from "../../../Models/Users/Users.Interface"; + +export class SysAdminSubscriptionsService { + public async extendUserSubscription( + params: SysAdminExtendUserSubscriptionParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + + if (!params.userId || !isValidObjectId(params.userId)) { + throw new Error("User id is required"); + } + + const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId }); + if (!subscription) { + throw new Error("No subscription found for user"); + } + + const now = new Date(); + const previousEndDate = subscription.endDate ? new Date(subscription.endDate) : now; + const baseDate = previousEndDate.getTime() > now.getTime() ? previousEndDate : now; + let newEndDate: Date; + + if (params.mode === "add_months") { + if (params.months !== 1 && params.months !== 2) { + throw new Error("Months must be 1 or 2"); + } + newEndDate = dayjs(baseDate).add(params.months, "month").toDate(); + } else if (params.mode === "set_end_date") { + if (!params.endDate) { + throw new Error("End date is required"); + } + const parsedEndDate = dayjs(params.endDate); + if (!parsedEndDate.isValid()) { + throw new Error("End date is invalid"); + } + newEndDate = parsedEndDate.endOf("day").toDate(); + if (newEndDate.getTime() <= now.getTime()) { + throw new Error("End date must be in the future"); + } + if (newEndDate.getTime() <= baseDate.getTime()) { + throw new Error("End date must be after the current effective subscription end date"); + } + } else { + throw new Error("Invalid extension mode"); + } + + subscription.endDate = newEndDate; + subscription.isActive = true; + if (subscription.mpStatus) { + subscription.mpStatus = "authorized"; + } + await subscription.save(); + + const transactionId = [ + "sysadmin_bonus", + String(subscription.id), + now.getTime(), + ].join(":"); + const payment = await PlanPaymentsList.createIfMissingByTransactionId({ + userId: params.userId, + subscriptionId: String(subscription.id), + amount: 0, + paymentDate: now, + paymentMethod: "sysadmin_bonus", + status: "completed", + transactionId, + }); + + return { + userId: params.userId, + subscriptionId: String(subscription.id), + previousEndDate, + newEndDate, + paymentId: String(payment.id), + transactionId, + }; + } + + public async getUserSubscriptionDetails( + params: SysAdminUserSubscriptionDetailsParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + + const subscription = await PlanSubscriptionsList.findOne({ sessionUser: params.userId }); + const payments = await PlanPaymentsList.find({ userId: params.userId }); + + if (!subscription) { + return { + userId: params.userId, + currentSubscription: null, + payments: payments.map((payment) => ({ + id: String(payment.id), + subscriptionId: String(payment.subscriptionId), + amount: payment.amount, + paymentDate: payment.paymentDate, + paymentMethod: payment.paymentMethod, + status: payment.status, + transactionId: payment.transactionId, + })), + }; + } + + const plan = await PlansList.plans.findOne({ _id: String(subscription.planId) }); + + return { + userId: params.userId, + currentSubscription: { + id: String(subscription.id), + plan: plan + ? { + id: String(plan.id), + name: plan.name, + code: plan.code, + price: plan.price, + } + : null, + startDate: subscription.startDate, + endDate: subscription.endDate, + isActive: subscription.isActive, + mpStatus: subscription.mpStatus, + billingMonths: subscription.billingMonths, + pendingPaymentType: subscription.pendingPaymentType, + pendingPaymentPreferenceId: subscription.pendingPaymentPreferenceId, + lastPaymentStatus: subscription.lastPaymentStatus, + }, + payments: payments.map((payment) => ({ + id: String(payment.id), + subscriptionId: String(payment.subscriptionId), + amount: payment.amount, + paymentDate: payment.paymentDate, + paymentMethod: payment.paymentMethod, + status: payment.status, + transactionId: payment.transactionId, + })), + }; + } +} diff --git a/sysadmin-cli/src/App.tsx b/sysadmin-cli/src/App.tsx index bba8362..7e002e0 100644 --- a/sysadmin-cli/src/App.tsx +++ b/sysadmin-cli/src/App.tsx @@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage'; import { UsersPage } from './pages/UsersPage'; import { WapServersPage } from './pages/WapServersPage'; import { CompaniesPage } from './pages/CompaniesPage'; +import { PlansPage } from './pages/PlansPage'; function App() { return ( @@ -13,6 +14,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/sysadmin-cli/src/api/sysadmin.ts b/sysadmin-cli/src/api/sysadmin.ts index a76b445..1ea6323 100644 --- a/sysadmin-cli/src/api/sysadmin.ts +++ b/sysadmin-cli/src/api/sysadmin.ts @@ -33,6 +33,119 @@ export const getOrganizationsStatus = async (userId: string) => { return res.data; }; +export type RecalculatePlanUsageCycleResult = { + userId: string; + subscriptionId: string; + planId: string; + cycleStart: string; + cycleEnd: string; + appointmentsCount: number; +}; + +export const recalculatePlanUsageCycle = async (userId: string): Promise => { + const res = await sysadminApi.post('/users/recalculate-plan-usage-cycle', { userId }); + return res.data; +}; + +export type UserSubscriptionDetails = { + userId: string; + currentSubscription: { + id: string; + plan: { + id: string; + name: string; + code: string; + price: number; + } | null; + startDate: string; + endDate: string; + isActive: boolean; + mpStatus?: string; + billingMonths?: number; + pendingPaymentType?: 'extension' | 'upgrade'; + pendingPaymentPreferenceId?: string; + lastPaymentStatus?: string; + } | null; + payments: { + id: string; + subscriptionId: string; + amount: number; + paymentDate: string; + paymentMethod?: string; + status: string; + transactionId?: string; + }[]; +}; + +export const getUserSubscriptionDetails = async (userId: string): Promise => { + const res = await sysadminApi.post('/users/subscription-details', { userId }); + return res.data; +}; + +export type ExtendUserSubscriptionPayload = { + userId: string; + mode: 'add_months' | 'set_end_date'; + months?: 1 | 2; + endDate?: string; + reason?: string; +}; + +export type ExtendUserSubscriptionResult = { + userId: string; + subscriptionId: string; + previousEndDate: string; + newEndDate: string; + paymentId: string; + transactionId?: string; +}; + +export const extendUserSubscription = async (data: ExtendUserSubscriptionPayload): Promise => { + const res = await sysadminApi.post('/users/extend-subscription', data); + return res.data; +}; + +export type Plan = { + id?: string; + _id?: string; + name: string; + description: string; + features: string[]; + code: string; + price: number; + annualPrice: number; + limitOrganizations: number; + limitEmployees: number; + limitServices: number; + limitAppointments: number; + limitClients: number; + limitRepeats: number; + mailNotifications: boolean; + smsNotifications: boolean; + wapNotifications: boolean; + payments: boolean; + bot: boolean; + active: boolean; + dateLimit: boolean; + discount3Months?: number; + discount6Months?: number; + discount12Months?: number; + featured?: boolean; +}; + +export type UpdatePlanPayload = Partial> & { + planId: string; +}; + +export const fetchPlans = async (): Promise => { + const res = await sysadminApi.post('/plans/list', {}); + return res.data; +}; + +export const updatePlan = async (data: UpdatePlanPayload) => { + const res = await sysadminApi.post('/plans/update', data); + return res.data; +}; + // Wap Servers API Calls export const fetchWapServers = async (filters: any) => { const res = await sysadminApi.post('/wap/paginate', filters); diff --git a/sysadmin-cli/src/components/layout/DashboardLayout.tsx b/sysadmin-cli/src/components/layout/DashboardLayout.tsx index 5a6afd4..ae7701e 100644 --- a/sysadmin-cli/src/components/layout/DashboardLayout.tsx +++ b/sysadmin-cli/src/components/layout/DashboardLayout.tsx @@ -1,5 +1,5 @@ import { Outlet, NavLink } from 'react-router-dom'; -import { Users, LayoutDashboard, LogOut, Server, Building2 } from 'lucide-react'; +import { Users, LayoutDashboard, LogOut, Server, Building2, BadgeDollarSign } from 'lucide-react'; import { motion } from 'framer-motion'; export const DashboardLayout = () => { @@ -30,6 +30,10 @@ export const DashboardLayout = () => { Organizaciones + `nav-item ${isActive ? 'active' : ''}`}> + + Planes + `nav-item ${isActive ? 'active' : ''}`}> Servidores WAP diff --git a/sysadmin-cli/src/components/ui/EditPlanModal.tsx b/sysadmin-cli/src/components/ui/EditPlanModal.tsx new file mode 100644 index 0000000..31e3e3c --- /dev/null +++ b/sysadmin-cli/src/components/ui/EditPlanModal.tsx @@ -0,0 +1,159 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { X } from 'lucide-react'; +import { updatePlan } from '../../api/sysadmin'; +import type { Plan } from '../../api/sysadmin'; + +type EditPlanModalProps = { + plan: Plan; + onClose: () => void; + onSaved: () => void; +}; + +const numberFields: { key: keyof Plan; label: string }[] = [ + { key: 'price', label: 'Precio mensual' }, + { key: 'annualPrice', label: 'Precio anual' }, + { key: 'limitOrganizations', label: 'Límite de organizaciones' }, + { key: 'limitEmployees', label: 'Límite de empleados' }, + { key: 'limitServices', label: 'Límite de servicios' }, + { key: 'limitAppointments', label: 'Límite de turnos' }, + { key: 'limitClients', label: 'Límite de clientes' }, + { key: 'limitRepeats', label: 'Límite de repeticiones' }, + { key: 'discount3Months', label: 'Descuento 3 meses (%)' }, + { key: 'discount6Months', label: 'Descuento 6 meses (%)' }, + { key: 'discount12Months', label: 'Descuento 12 meses (%)' }, +]; + +const limitFields = new Set([ + 'limitOrganizations', + 'limitEmployees', + 'limitServices', + 'limitAppointments', + 'limitClients', + 'limitRepeats', +]); + +const booleanFields: { key: keyof Plan; label: string }[] = [ + { key: 'active', label: 'Activo para contratación' }, + { key: 'featured', label: 'Destacado' }, + { key: 'mailNotifications', label: 'Notificaciones por email' }, + { key: 'smsNotifications', label: 'Notificaciones SMS' }, + { key: 'wapNotifications', label: 'Notificaciones WhatsApp' }, + { key: 'payments', label: 'Pagos habilitados' }, + { key: 'bot', label: 'Bot habilitado' }, + { key: 'dateLimit', label: 'Tiene límite de fecha' }, +]; + +export const EditPlanModal = ({ plan, onClose, onSaved }: EditPlanModalProps) => { + const [formData, setFormData] = useState({ + ...plan, + features: Array.isArray(plan.features) ? plan.features : [], + }); + const [featuresText, setFeaturesText] = useState(formData.features.join('\n')); + const [loading, setLoading] = useState(false); + + const planId = plan._id || plan.id || ''; + + const setField = (key: keyof Plan, value: string | number | boolean | string[]) => { + setFormData({ ...formData, [key]: value }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + await updatePlan({ + planId, + name: formData.name, + description: formData.description, + code: formData.code, + price: Number(formData.price) || 0, + annualPrice: Number(formData.annualPrice) || 0, + limitOrganizations: Number(formData.limitOrganizations) || 0, + limitEmployees: Number(formData.limitEmployees) || 0, + limitServices: Number(formData.limitServices) || 0, + limitAppointments: Number(formData.limitAppointments) || 0, + limitClients: Number(formData.limitClients) || 0, + limitRepeats: Number(formData.limitRepeats) || 0, + discount3Months: Number(formData.discount3Months) || 0, + discount6Months: Number(formData.discount6Months) || 0, + discount12Months: Number(formData.discount12Months) || 0, + active: Boolean(formData.active), + featured: Boolean(formData.featured), + mailNotifications: Boolean(formData.mailNotifications), + smsNotifications: Boolean(formData.smsNotifications), + wapNotifications: Boolean(formData.wapNotifications), + payments: Boolean(formData.payments), + bot: Boolean(formData.bot), + dateLimit: Boolean(formData.dateLimit), + features: featuresText.split('\n').map(feature => feature.trim()).filter(Boolean), + }); + onSaved(); + onClose(); + } catch (err: any) { + alert(err.response?.data?.message || err.message || 'Error guardando el plan'); + } + setLoading(false); + }; + + return ( +
+ + + +

Editar plan: {plan.name}

+ +
+
+ + setField('name', e.target.value)} required /> +
+
+ + setField('code', e.target.value)} required /> +
+
+ +