feat: implement sysadmin subscription management and plan usage cycle recalculation tools

This commit is contained in:
2026-07-18 21:32:47 -03:00
parent 1c8c47cf76
commit 4f3b56784e
36 changed files with 2146 additions and 21 deletions
+22 -7
View File
@@ -305,12 +305,6 @@ class AppointmentManager implements IAppointmentsManager {
throw new Error("La compañia no existe"); 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({ const serviceCheck = await ServicesManager.services.findOne({
_id: data.serviceId, _id: data.serviceId,
companyId: data.companyId, companyId: data.companyId,
@@ -443,6 +437,18 @@ class AppointmentManager implements IAppointmentsManager {
data.comments = isNull<string>(data.comments, ""); data.comments = isNull<string>(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 { try {
const start = dayjs(data.start).clone(); const start = dayjs(data.start).clone();
const end = dayjs(data.start) const end = dayjs(data.start)
@@ -467,6 +473,7 @@ class AppointmentManager implements IAppointmentsManager {
comments: data.comments, comments: data.comments,
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
appointmentCreated = true;
if (data.repeatId) { if (data.repeatId) {
const repeat = await RepeatsList.repeats.findOne({ const repeat = await RepeatsList.repeats.findOne({
@@ -489,8 +496,9 @@ class AppointmentManager implements IAppointmentsManager {
} }
} }
await MetricsList.addAppointment({ await MetricsList.metrics.addAppointment({
userId: companyCheck.ownerId, userId: companyCheck.ownerId,
companyId: data.companyId,
quantity: 1, quantity: 1,
}); });
@@ -559,6 +567,13 @@ class AppointmentManager implements IAppointmentsManager {
return newAppointment; return newAppointment;
} catch (err) { } catch (err) {
console.log(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."); throw new Error("Ha ocurrido un error al crear el turno.");
} }
} }
@@ -2,7 +2,9 @@ import { IMetricsDocument } from "./Metrics.Adapter.Mongoose";
export type MetricsParams = { export type MetricsParams = {
userId?: string; userId?: string;
companyId?: string;
quantity?: number; quantity?: number;
limit?: number;
}; };
export type DeleteMetricsByCompany = { export type DeleteMetricsByCompany = {
@@ -45,6 +47,8 @@ export interface IMetricsManager {
addEmployee(data: MetricsParams): Promise<void>; addEmployee(data: MetricsParams): Promise<void>;
addService(data: MetricsParams): Promise<void>; addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>; addAppointment(data: MetricsParams): Promise<void>;
reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>; canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>; canAddEmployee(userId: string): Promise<boolean>;
+50 -2
View File
@@ -14,6 +14,7 @@ import AppointmentList from "../Appointments/Appointments";
import ClientsList from "../Clients/Clients"; import ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats"; import RepeatsList from "../Repeats/Repeats";
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import PlanUsageCycleList from "../PlanUsageCycle/PlanUsageCycle";
class MetricsManager implements IMetricsManager { class MetricsManager implements IMetricsManager {
metrics: MetricsAdapterMongoose; metrics: MetricsAdapterMongoose;
@@ -231,17 +232,64 @@ class MetricsManager implements IMetricsManager {
if (plan.limitAppointments < 0) { if (plan.limitAppointments < 0) {
return true; return true;
} }
const metrics = await this.getMetrics(userId); const appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({ userId });
if (metrics.appointmentsCount >= plan.limitAppointments) { if (appointmentsCount >= plan.limitAppointments) {
return false; return false;
} }
return true; return true;
} }
public async addAppointment(data: MetricsParams): Promise<void> { public async addAppointment(data: MetricsParams): Promise<void> {
if (data.userId) {
await PlanUsageCycleList.addAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
await this.metrics.addAppointment(data); await this.metrics.addAppointment(data);
} }
public async reserveAppointment(data: MetricsParams): Promise<boolean> {
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<void> {
if (data.userId) {
await PlanUsageCycleList.releaseAppointment({
userId: data.userId,
quantity: data.quantity,
});
}
}
public async addClient(data: MetricsParams): Promise<void> { public async addClient(data: MetricsParams): Promise<void> {
await this.metrics.addClient(data); await this.metrics.addClient(data);
} }
@@ -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<IPlanUsageCycle, "id">, Document {}
export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
schema: Schema;
planUsageCycleList: Model<IPlanUsageCycleDocument>;
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<IPlanUsageCycleDocument>("PlanUsageCycle", this.schema);
}
public async findOne(filters: FindPlanUsageCycleParams): Promise<IPlanUsageCycleDocument | null> {
return await this.planUsageCycleList.findOne(filters);
}
public async create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
return await this.planUsageCycleList.create({
...data,
appointmentsCount: data.appointmentsCount || 0,
updateDate: new Date(),
});
}
public async getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument> {
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<IPlanUsageCycleDocument | null> {
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<IPlanUsageCycleDocument | null> {
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<IPlanUsageCycleDocument | null> {
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 }
);
}
}
@@ -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<IPlanUsageCycleDocument | null>;
create(data: CreatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
getOrCreateCurrent(data: UpdatePlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
incrementAppointments(data: UpdatePlanUsageCycleParams & { quantity: number }): Promise<IPlanUsageCycleDocument | null>;
incrementAppointmentsIfWithinLimit(
data: UpdatePlanUsageCycleParams & { quantity: number; limit: number }
): Promise<IPlanUsageCycleDocument | null>;
setAppointmentsCount(
data: UpdatePlanUsageCycleParams & { appointmentsCount: number }
): Promise<IPlanUsageCycleDocument | null>;
}
export interface IPlanUsageCycleManager {
planUsageCycle: IPlanUsageCycleAdapter;
getCurrentCycle(data: CurrentPlanUsageCycleParams): Promise<IPlanUsageCycleDocument>;
addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean>;
releaseAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void>;
getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number>;
setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null>;
}
@@ -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(),
};
}
@@ -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<UpdatePlanUsageCycleParams> {
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<IPlanUsageCycleDocument> {
return await this.planUsageCycle.getOrCreateCurrent(await this.getCurrentCycleData(data));
}
public async addAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<void> {
await this.planUsageCycle.incrementAppointments({
...(await this.getCurrentCycleData(data)),
quantity: isNull<number>(data.quantity, 1),
});
}
public async reserveAppointment(data: CurrentPlanUsageCycleParams & { quantity?: number }): Promise<boolean> {
if (typeof data.limit !== "number") {
return false;
}
const quantity = isNull<number>(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<void> {
await this.addAppointment({
...data,
quantity: -Math.abs(isNull<number>(data.quantity, 1)),
});
}
public async getAppointmentsCount(data: CurrentPlanUsageCycleParams): Promise<number> {
const usageCycle = await this.getCurrentCycle(data);
return usageCycle.appointmentsCount;
}
public async setAppointmentsCount(data: SetPlanUsageCycleAppointmentsParams): Promise<IPlanUsageCycleDocument | null> {
return await this.planUsageCycle.setAppointmentsCount({
...(await this.getCurrentCycleData(data)),
appointmentsCount: data.appointmentsCount,
});
}
}
const PlanUsageCycleList = new PlanUsageCycleManager();
export default PlanUsageCycleList;
@@ -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"; import { Document, Model, Schema, model } from "mongoose";
export interface IPlanDocument extends Omit<IPlan, "id">, Document {} export interface IPlanDocument extends Omit<IPlan, "id">, Document {}
@@ -45,4 +45,8 @@ export class PlansAdapterMongoose implements IPlansAdapter {
public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> { public async findOne(filters: FindPlansParams): Promise<IPlanDocument | null> {
return this.planList.findOne(filters).exec(); return this.planList.findOne(filters).exec();
} }
public async updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void> {
await this.planList.updateOne({ _id: planId }, { $set: data }).exec();
}
} }
@@ -12,6 +12,33 @@ export type FindPlansParams = {
price?: number; 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 { export interface IPlan {
id?: string; id?: string;
name: string; name: string;
@@ -44,10 +71,12 @@ export interface IPlan {
export interface IPlansAdapter { export interface IPlansAdapter {
find(filters: FindPlansParams): Promise<IPlan[]>; find(filters: FindPlansParams): Promise<IPlan[]>;
findOne(filters: FindPlansParams): Promise<IPlan | null>; findOne(filters: FindPlansParams): Promise<IPlan | null>;
updateOne(planId: string, data: Omit<SysAdminUpdatePlanParams, "planId">): Promise<void>;
} }
export interface IPlansManager { export interface IPlansManager {
plans: IPlansAdapter; plans: IPlansAdapter;
find(data: FindPlansParams): Promise<IPlan[]>; find(data: FindPlansParams): Promise<IPlan[]>;
findOne(data: FindPlansParams): Promise<IPlan>; findOne(data: FindPlansParams): Promise<IPlan>;
sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void>;
} }
+7 -1
View File
@@ -1,6 +1,6 @@
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import { PlansAdapterMongoose } from "./Plans.Adapter.Mongoose"; 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 { class PlansManager implements IPlansManager {
plans: PlansAdapterMongoose; plans: PlansAdapterMongoose;
@@ -59,6 +59,12 @@ class PlansManager implements IPlansManager {
return this.getOnePlan(plan); return this.getOnePlan(plan);
} }
public async sysAdminUpdatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
const { planId, ...planData } = data;
await this.findOne({ _id: planId });
await this.plans.updateOne(planId, planData);
}
} }
const PlansList = new PlansManager(); const PlansList = new PlansManager();
@@ -205,6 +205,61 @@ export type SysAdminOrganizationsStatusParams = {
userId: string; 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 = { export type SysAdminUserOrganizationsResult = {
clientIn: { companyId: string; companyName: string }[]; clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { companyId: string; companyName: string }[]; collaboratorIn: { companyId: string; companyName: string }[];
@@ -10,6 +10,7 @@ import CashFlowList from "../../Models/CashFlow/CashFlow";
import CompaniesList from "../../Models/Companies/Companies"; import CompaniesList from "../../Models/Companies/Companies";
import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons"; import PlanSubscriptionsList from "../../Models/PlanSubscriptions/PlanSubscriptons";
import MetricsList from "../../Models/Metrics/Metrics"; import MetricsList from "../../Models/Metrics/Metrics";
import PlanUsageCycleList from "../../Models/PlanUsageCycle/PlanUsageCycle";
export class DashboardService { export class DashboardService {
public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> { public async getDashboardSummary(sessionUser: string): Promise<DashboardSummaryResult> {
@@ -198,11 +199,18 @@ export class DashboardService {
const isOwner = String(ownerId) === String(params.sessionUser); const isOwner = String(ownerId) === String(params.sessionUser);
// Fetch metrics for owner // 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 // Fetch subscription for owner
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) }); const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ sessionUser: String(ownerId) });
if (subscription) {
metrics.appointmentsCount = await PlanUsageCycleList.getAppointmentsCount({
userId: String(ownerId),
});
}
return { return {
metrics, metrics,
subscription, subscription,
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("recalculate-user")
public async recalculateUserUsageCycle(
@Body() requestBody: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult | ApiValidationError> {
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);
}
}
}
@@ -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<SysAdminRecalculatePlanUsageCycleResult> {
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,
};
}
}
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("list")
public async listPlans(): Promise<IPlan[] | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El plan fue modificado con éxito")
@Post("update")
public async updatePlan(@Body() requestBody: SysAdminUpdatePlanParams): Promise<ApiVoidResult | ApiValidationError> {
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);
}
}
}
@@ -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<IPlan[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await PlansList.find({});
}
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<void> {
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;
}
}
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("extend-user")
public async extendUserSubscription(
@Body() requestBody: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post("user-details")
public async getUserSubscriptionDetails(
@Body() requestBody: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult | ApiValidationError> {
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);
}
}
}
@@ -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<SysAdminExtendUserSubscriptionResult> {
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<SysAdminUserSubscriptionDetailsResult> {
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,
})),
};
}
}
+2
View File
@@ -4,6 +4,7 @@ import { DashboardPage } from './pages/DashboardPage';
import { UsersPage } from './pages/UsersPage'; import { UsersPage } from './pages/UsersPage';
import { WapServersPage } from './pages/WapServersPage'; import { WapServersPage } from './pages/WapServersPage';
import { CompaniesPage } from './pages/CompaniesPage'; import { CompaniesPage } from './pages/CompaniesPage';
import { PlansPage } from './pages/PlansPage';
function App() { function App() {
return ( return (
@@ -13,6 +14,7 @@ function App() {
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="companies" element={<CompaniesPage />} /> <Route path="companies" element={<CompaniesPage />} />
<Route path="plans" element={<PlansPage />} />
<Route path="wapservers" element={<WapServersPage />} /> <Route path="wapservers" element={<WapServersPage />} />
</Route> </Route>
</Routes> </Routes>
+113
View File
@@ -33,6 +33,119 @@ export const getOrganizationsStatus = async (userId: string) => {
return res.data; 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<RecalculatePlanUsageCycleResult> => {
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<UserSubscriptionDetails> => {
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<ExtendUserSubscriptionResult> => {
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<Omit<Plan, 'id' | '_id'>> & {
planId: string;
};
export const fetchPlans = async (): Promise<Plan[]> => {
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 // Wap Servers API Calls
export const fetchWapServers = async (filters: any) => { export const fetchWapServers = async (filters: any) => {
const res = await sysadminApi.post('/wap/paginate', filters); const res = await sysadminApi.post('/wap/paginate', filters);
@@ -1,5 +1,5 @@
import { Outlet, NavLink } from 'react-router-dom'; 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'; import { motion } from 'framer-motion';
export const DashboardLayout = () => { export const DashboardLayout = () => {
@@ -30,6 +30,10 @@ export const DashboardLayout = () => {
<Building2 size={20} /> <Building2 size={20} />
Organizaciones Organizaciones
</NavLink> </NavLink>
<NavLink to="/plans" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<BadgeDollarSign size={20} />
Planes
</NavLink>
<NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}> <NavLink to="/wapservers" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<Server size={20} /> <Server size={20} />
Servidores WAP Servidores WAP
@@ -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<keyof Plan>([
'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>({
...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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="glass-panel" style={{ width: '94%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}>
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
<X size={24} />
</button>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>Editar plan: {plan.name}</h2>
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '1rem' }}>
<div>
<label className="modal-label">Nombre</label>
<input className="input-glass" value={formData.name} onChange={e => setField('name', e.target.value)} required />
</div>
<div>
<label className="modal-label">Código</label>
<input className="input-glass" value={formData.code} onChange={e => setField('code', e.target.value)} required />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label className="modal-label">Descripción</label>
<textarea className="input-glass" rows={3} value={formData.description} onChange={e => setField('description', e.target.value)} required />
</div>
<h3 className="plan-modal-section">Precios, límites y descuentos</h3>
{numberFields.map(field => (
<div key={field.key}>
<label className="modal-label">{field.label}</label>
<input className="input-glass" type="number" min={limitFields.has(field.key) ? undefined : '0'} step="0.01" value={Number(formData[field.key] || 0)} onChange={e => setField(field.key, Number(e.target.value))} />
</div>
))}
<h3 className="plan-modal-section">Funciones y estado</h3>
{booleanFields.map(field => (
<label key={field.key} className="plan-toggle">
<input type="checkbox" checked={Boolean(formData[field.key])} onChange={e => setField(field.key, e.target.checked)} />
{field.label}
</label>
))}
<div style={{ gridColumn: '1 / -1' }}>
<label className="modal-label">Features visibles (una por línea)</label>
<textarea className="input-glass" rows={5} value={featuresText} onChange={e => setFeaturesText(e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1', marginTop: '1rem', display: 'flex', justifyContent: 'flex-end', gap: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}>Cancelar</button>
<button type="submit" className="btn-primary" disabled={loading || !planId}>{loading ? 'Guardando...' : 'Guardar cambios'}</button>
</div>
</form>
<style dangerouslySetInnerHTML={{__html: `
.modal-label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.5rem; }
.plan-modal-section { grid-column: 1 / -1; margin: 1rem 0 0.25rem; color: var(--primary); font-size: 1.05rem; }
.plan-toggle { display: flex; align-items: center; gap: 0.6rem; color: var(--text-main); background: rgba(255,255,255,0.04); border: 1px solid var(--glass-border); border-radius: 10px; padding: 0.75rem; }
.plan-toggle input { width: 16px; height: 16px; }
@media (max-width: 768px) { form { grid-template-columns: 1fr !important; } }
`}} />
</motion.div>
</div>
);
};
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { RefreshCw, X } from 'lucide-react';
import { recalculatePlanUsageCycle, type RecalculatePlanUsageCycleResult } from '../../api/sysadmin';
import { formatDateTime } from '../../utils/formatters';
export const RecalculationToolsModal = ({ initialUserId = '', onClose }: { initialUserId?: string, onClose: () => void }) => {
const [usageUserId, setUsageUserId] = useState(initialUserId);
const [usageResult, setUsageResult] = useState<RecalculatePlanUsageCycleResult | null>(null);
const [usageLoading, setUsageLoading] = useState(false);
const [usageError, setUsageError] = useState('');
const handleRecalculatePlanUsage = async () => {
const trimmedUserId = usageUserId.trim();
if (!trimmedUserId) {
setUsageError('Ingresá un userId para recalcular el uso del plan.');
return;
}
setUsageUserId(trimmedUserId);
setUsageLoading(true);
setUsageError('');
setUsageResult(null);
try {
const result = await recalculatePlanUsageCycle(trimmedUserId);
setUsageResult(result);
} catch (e: any) {
setUsageError(e.response?.data?.message || e.message || 'Error al recalcular el uso del plan');
} finally {
setUsageLoading(false);
}
};
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="glass-panel"
style={{ width: '90%', maxWidth: '760px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
>
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
<X size={24} />
</button>
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
<RefreshCw size={24} color="var(--primary)" />
Herramientas de recálculo
</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Funciones exclusivas de sysadmin para reparar o reconstruir contadores.</p>
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem' }}>
<div style={{ marginBottom: '1rem' }}>
<h3 style={{ fontSize: '1.1rem', fontWeight: '700', marginBottom: '0.35rem' }}>Recalcular uso del plan</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Recuenta las reservas del ciclo actual para el userId seleccionado.</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleRecalculatePlanUsage(); }} style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 300px' }}>
<label className="modal-label">User ID</label>
<input
type="text"
className="input-glass"
placeholder="Pegá o seleccioná un userId"
value={usageUserId}
onChange={(e) => setUsageUserId(e.target.value)}
/>
</div>
<button type="submit" className="btn-primary" disabled={usageLoading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> {usageLoading ? 'Recalculando...' : 'Recalcular'}
</button>
</form>
{usageError && <div style={{ marginTop: '1rem', color: 'var(--danger)', fontSize: '0.9rem' }}>{usageError}</div>}
{usageResult && (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div><strong>User ID</strong><br /><span className="long-id-value">{usageResult.userId}</span></div>
<div><strong>Subscription ID</strong><br /><span className="long-id-value">{usageResult.subscriptionId}</span></div>
<div><strong>Plan ID</strong><br /><span className="long-id-value">{usageResult.planId}</span></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.75rem' }}>
<div><strong>Cycle Start</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleStart)}</span></div>
<div><strong>Cycle End</strong><br /><span style={{ color: 'var(--text-muted)' }}>{formatDateTime(usageResult.cycleEnd)}</span></div>
<div><strong>Appointments</strong><br /><span style={{ color: 'var(--success)', fontWeight: 700 }}>{usageResult.appointmentsCount}</span></div>
</div>
</div>
)}
</div>
<style dangerouslySetInnerHTML={{__html: `
.modal-label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.long-id-value {
display: block;
color: var(--text-muted);
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.45;
}
`}} />
</motion.div>
</div>
);
};
@@ -0,0 +1,240 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CreditCard, X } from 'lucide-react';
import { extendUserSubscription, getUserSubscriptionDetails, type UserSubscriptionDetails } from '../../api/sysadmin';
import { formatDateTime } from '../../utils/formatters';
type Props = {
user: any;
onClose: () => void;
};
export const SubscriptionDetailsModal = ({ user, onClose }: Props) => {
const [details, setDetails] = useState<UserSubscriptionDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [extensionDate, setExtensionDate] = useState('');
const [extensionLoading, setExtensionLoading] = useState(false);
const [extensionMessage, setExtensionMessage] = useState('');
const [extensionError, setExtensionError] = useState('');
const formatAmount = (value?: number) => {
if (typeof value !== 'number') return '-';
return value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' });
};
const loadDetails = async () => {
setLoading(true);
setError('');
try {
const result = await getUserSubscriptionDetails(user._id);
setDetails(result);
} catch (e: any) {
setError(e.response?.data?.message || e.message || 'Error cargando suscripción');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDetails();
}, [user._id]);
const handleExtend = async (payload: { mode: 'add_months' | 'set_end_date'; months?: 1 | 2; endDate?: string }) => {
if (!details?.currentSubscription) return;
setExtensionLoading(true);
setExtensionError('');
setExtensionMessage('');
try {
const result = await extendUserSubscription({
userId: user._id,
reason: 'Ajuste manual excepcional desde sysadmin-cli',
...payload,
});
setExtensionMessage(`Vencimiento actualizado hasta ${formatDateTime(result.newEndDate)}. Se registró una bonificación $0.`);
await loadDetails();
} catch (e: any) {
setExtensionError(e.response?.data?.message || e.message || 'Error extendiendo suscripción');
} finally {
setExtensionLoading(false);
}
};
const subscription = details?.currentSubscription;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', padding: '1rem' }}>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="glass-panel"
style={{ width: '90%', maxWidth: '860px', maxHeight: '90vh', overflowY: 'auto', padding: '2rem', position: 'relative' }}
>
<button onClick={onClose} style={{ position: 'absolute', top: '1.5rem', right: '1.5rem', background: 'transparent', color: 'var(--text-muted)' }}>
<X size={24} />
</button>
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
<CreditCard size={24} color="var(--primary)" />
Suscripción del usuario
</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
{user.firstName} {user.lastName} · {user.email}
</p>
{loading && <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Cargando suscripción...</div>}
{error && <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>}
{!loading && !error && details && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<section className="subscription-card">
<h3 className="subscription-section-title">Suscripción actual</h3>
{!subscription ? (
<p style={{ color: 'var(--text-muted)' }}>No hay suscripción registrada para este usuario.</p>
) : (
<div className="subscription-grid">
<Info label="Subscription ID" value={subscription.id} long />
<Info label="User ID" value={details.userId} long />
<Info label="Plan" value={subscription.plan ? `${subscription.plan.name} (${subscription.plan.code})` : 'Plan no encontrado'} />
<Info label="Plan ID" value={subscription.plan?.id || '-'} long />
<Info label="Precio" value={formatAmount(subscription.plan?.price)} />
<Info label="Inicio" value={formatDateTime(subscription.startDate)} />
<Info label="Válida hasta" value={formatDateTime(subscription.endDate)} />
<Info label="Activa" value={subscription.isActive ? 'Sí' : 'No'} />
<Info label="MP status" value={subscription.mpStatus || '-'} />
<Info label="Meses facturados" value={subscription.billingMonths?.toString() || '-'} />
<Info label="Pago pendiente" value={subscription.pendingPaymentType || '-'} />
<Info label="Preference pendiente" value={subscription.pendingPaymentPreferenceId || '-'} long />
<Info label="Último pago" value={subscription.lastPaymentStatus || '-'} />
</div>
)}
</section>
{subscription && (
<section className="subscription-card">
<h3 className="subscription-section-title">Ajuste manual excepcional</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
Extiende la vigencia sin pago y registra un pago de bonificación por $0 asociado a esta suscripción.
</p>
<div className="extension-actions">
<button className="btn btn-secondary" disabled={extensionLoading} onClick={() => handleExtend({ mode: 'add_months', months: 1 })}>
Extender 1 mes
</button>
<button className="btn btn-secondary" disabled={extensionLoading} onClick={() => handleExtend({ mode: 'add_months', months: 2 })}>
Extender 2 meses
</button>
<div className="extension-date-action">
<input
type="date"
value={extensionDate}
onChange={(event) => setExtensionDate(event.target.value)}
disabled={extensionLoading}
/>
<button
className="btn btn-primary"
disabled={extensionLoading || !extensionDate}
onClick={() => handleExtend({ mode: 'set_end_date', endDate: extensionDate })}
>
Establecer vencimiento
</button>
</div>
</div>
{extensionLoading && <p style={{ color: 'var(--text-muted)', marginTop: '0.75rem' }}>Aplicando ajuste...</p>}
{extensionMessage && <p style={{ color: 'var(--success)', marginTop: '0.75rem' }}>{extensionMessage}</p>}
{extensionError && <p style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>{extensionError}</p>}
</section>
)}
<section className="subscription-card">
<h3 className="subscription-section-title">Pagos</h3>
{details.payments.length === 0 ? (
<p style={{ color: 'var(--text-muted)' }}>No hay pagos registrados para este usuario.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{details.payments.map((payment) => (
<div key={payment.id} className="payment-row">
<Info label="Payment ID" value={payment.id} long />
<Info label="Subscription ID" value={payment.subscriptionId} long />
<Info label="Monto" value={formatAmount(payment.amount)} />
<Info label="Fecha" value={formatDateTime(payment.paymentDate)} />
<Info label="Método" value={payment.paymentMethod || '-'} />
<Info label="Estado" value={payment.status} />
<Info label="Transaction ID" value={payment.transactionId || '-'} long />
</div>
))}
</div>
)}
</section>
</div>
)}
<style dangerouslySetInnerHTML={{__html: `
.subscription-card {
background: rgba(255,255,255,0.03);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 1.25rem;
}
.subscription-section-title {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1rem;
}
.subscription-grid,
.payment-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.85rem;
}
.payment-row {
border-top: 1px solid var(--glass-border);
padding-top: 0.75rem;
}
.subscription-label {
display: block;
color: var(--text-muted);
font-size: 0.78rem;
margin-bottom: 0.25rem;
}
.subscription-value {
display: block;
color: var(--text-main);
font-weight: 600;
}
.subscription-value.long {
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.45;
}
.extension-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.extension-date-action {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.extension-date-action input {
background: rgba(255,255,255,0.06);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-main);
padding: 0.65rem 0.75rem;
}
`}} />
</motion.div>
</div>
);
};
const Info = ({ label, value, long = false }: { label: string; value: string; long?: boolean }) => (
<div>
<span className="subscription-label">{label}</span>
<span className={`subscription-value${long ? ' long' : ''}`}>{value}</span>
</div>
);
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { CheckCircle, Edit2, RefreshCw, Star, XCircle } from 'lucide-react';
import { fetchPlans } from '../api/sysadmin';
import type { Plan } from '../api/sysadmin';
import { EditPlanModal } from '../components/ui/EditPlanModal';
const formatMoney = (value: number) => new Intl.NumberFormat('es-AR', { style: 'currency', currency: 'ARS' }).format(value || 0);
const getFeatureTags = (plan: Plan) => [
{ label: 'Mail', enabled: plan.mailNotifications },
{ label: 'SMS', enabled: plan.smsNotifications },
{ label: 'WAP', enabled: plan.wapNotifications },
{ label: 'Pagos', enabled: plan.payments },
{ label: 'Bot', enabled: plan.bot },
{ label: 'Fecha límite', enabled: plan.dateLimit },
];
const getLimitTags = (plan: Plan) => [
{ label: 'Org', value: plan.limitOrganizations },
{ label: 'Emp', value: plan.limitEmployees },
{ label: 'Serv', value: plan.limitServices },
{ label: 'Turnos', value: plan.limitAppointments },
{ label: 'Clientes', value: plan.limitClients },
{ label: 'Rep', value: plan.limitRepeats },
];
const formatLimit = (value: number) => value === -1 ? '∞' : value;
export const PlansPage = () => {
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [planIdFilter, setPlanIdFilter] = useState('');
const filteredPlans = plans.filter((plan) => {
const normalizedFilter = planIdFilter.trim().toLowerCase();
if (!normalizedFilter) return true;
return [plan.id, plan._id].some((planId) => planId?.toLowerCase().includes(normalizedFilter));
});
const loadPlans = async () => {
setLoading(true);
setError('');
try {
const data = await fetchPlans();
setPlans(data);
} catch (err: any) {
setError(err.response?.data?.message || err.message || 'Error cargando planes');
}
setLoading(false);
};
useEffect(() => {
loadPlans();
}, []);
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in plans-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<div>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Planes</h1>
<p style={{ color: 'var(--text-muted)', marginTop: '0.35rem' }}>Visualizá y editá precios, límites y funciones contratables.</p>
</div>
<button className="btn-secondary" onClick={loadPlans} disabled={loading} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> Actualizar
</button>
</div>
<div className="glass-panel" style={{ padding: '1rem', marginBottom: '1rem', flexShrink: 0 }}>
<label style={{ display: 'block', color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '0.5rem' }}>
Filtrar por ID de plan
</label>
<input
className="input-glass"
type="text"
placeholder="Pegá el id del plan"
value={planIdFilter}
onChange={(event) => setPlanIdFilter(event.target.value)}
/>
</div>
{error && (
<div className="glass-panel" style={{ padding: '1rem', marginBottom: '1rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.35)' }}>
{error}
</div>
)}
<div className="glass-panel" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', flex: 1 }}>
<div style={{ overflow: 'auto', flex: 1 }} className="table-scroll-container">
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', minWidth: '920px' }}>
<thead>
<tr>
<th className="sticky-header">Plan</th>
<th className="sticky-header">Precio</th>
<th className="sticky-header">Límites</th>
<th className="sticky-header">Funciones</th>
<th className="sticky-header">Estado</th>
<th className="sticky-header" style={{ textAlign: 'right' }}>Acciones</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando planes...</td></tr>
) : filteredPlans.length === 0 ? (
<tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron planes</td></tr>
) : (
filteredPlans.map(plan => (
<tr key={plan._id || plan.id || plan.code} className="table-row-hover" style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<td style={{ padding: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 700 }}>
{plan.name} {plan.featured && <Star size={16} color="var(--warning)" fill="var(--warning)" />}
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{plan.code}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginTop: '0.35rem', maxWidth: '280px' }}>{plan.description}</div>
</td>
<td style={{ padding: '1rem' }}>
<div>{formatMoney(plan.price)} / mes</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{formatMoney(plan.annualPrice)} / año</div>
</td>
<td style={{ padding: '1rem' }}>
<div className="limit-tags">
{getLimitTags(plan).map((limit) => (
<span key={limit.label} className={`limit-tag ${limit.value === -1 ? 'unlimited' : ''}`}>
<small>{limit.label}</small>
<strong>{formatLimit(limit.value)}</strong>
</span>
))}
</div>
</td>
<td style={{ padding: '1rem' }}>
<div className="feature-tags">
{getFeatureTags(plan).map((feature) => (
<span key={feature.label} className={`feature-tag ${feature.enabled ? 'enabled' : 'disabled'}`}>
{feature.label}
</span>
))}
</div>
</td>
<td style={{ padding: '1rem' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', color: plan.active ? 'var(--success)' : 'var(--warning)' }}>
{plan.active ? <CheckCircle size={18} /> : <XCircle size={18} />}
{plan.active ? 'Activo' : 'Inactivo'}
</span>
</td>
<td style={{ padding: '1rem', textAlign: 'right' }}>
<button className="icon-btn" title="Editar plan" aria-label={`Editar ${plan.name}`} onClick={() => setEditingPlan(plan)}>
<Edit2 size={18} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<style dangerouslySetInnerHTML={{__html: `
.sticky-header { position: sticky; top: 0; z-index: 10; background: rgba(10, 10, 15, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid var(--glass-border); padding: 1rem; color: var(--text-muted); font-weight: 600; }
.table-row-hover:hover { background: rgba(255, 255, 255, 0.02); }
.limit-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 300px; }
.limit-tag { display: inline-flex; align-items: center; gap: 0.3rem; border-radius: 999px; padding: 0.25rem 0.55rem; background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.24); color: #bfdbfe; }
.limit-tag small { color: #93c5fd; font-weight: 800; font-size: 0.7rem; text-transform: uppercase; }
.limit-tag strong { color: #f8fafc; font-size: 0.82rem; }
.limit-tag.unlimited { background: rgba(168, 85, 247, 0.16); border-color: rgba(168, 85, 247, 0.32); }
.limit-tag.unlimited small { color: #d8b4fe; }
.limit-tag.unlimited strong { color: #f5d0fe; font-size: 0.95rem; }
.feature-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; max-width: 260px; }
.feature-tag { display: inline-flex; align-items: center; border-radius: 999px; padding: 0.25rem 0.55rem; font-size: 0.75rem; font-weight: 700; border: 1px solid transparent; }
.feature-tag.enabled { background: rgba(34, 197, 94, 0.14); color: #86efac; border-color: rgba(34, 197, 94, 0.28); }
.feature-tag.disabled { background: rgba(148, 163, 184, 0.1); color: #94a3b8; border-color: rgba(148, 163, 184, 0.18); text-decoration: line-through; opacity: 0.75; }
@media (max-width: 768px) { .plans-page-container { height: auto !important; overflow: visible !important; } .table-scroll-container { overflow-x: auto; } }
`}} />
{editingPlan && <EditPlanModal plan={editingPlan} onClose={() => setEditingPlan(null)} onSaved={loadPlans} />}
</motion.div>
);
};
+114 -8
View File
@@ -1,15 +1,20 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react'; import { Search, Edit2, Trash2, CheckCircle, XCircle, Building2, ChevronUp, ChevronDown, RotateCcw, RefreshCw, MoreVertical, CreditCard } from 'lucide-react';
import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin'; import { fetchUsers, deleteUser, setVerifiedStatus } from '../api/sysadmin';
import { EditUserModal } from '../components/ui/EditUserModal'; import { EditUserModal } from '../components/ui/EditUserModal';
import { OrganizationsModal } from '../components/ui/OrganizationsModal'; import { OrganizationsModal } from '../components/ui/OrganizationsModal';
import { RecalculationToolsModal } from '../components/ui/RecalculationToolsModal';
import { SubscriptionDetailsModal } from '../components/ui/SubscriptionDetailsModal';
import { formatDate } from '../utils/formatters';
export const UsersPage = () => { export const UsersPage = () => {
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [recalculationUserId, setRecalculationUserId] = useState('');
const [openActionsUserId, setOpenActionsUserId] = useState<string | null>(null);
// Filters // Filters
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
@@ -23,6 +28,8 @@ export const UsersPage = () => {
// Modals state // Modals state
const [editingUser, setEditingUser] = useState<any>(null); const [editingUser, setEditingUser] = useState<any>(null);
const [orgUser, setOrgUser] = useState<any>(null); const [orgUser, setOrgUser] = useState<any>(null);
const [subscriptionUser, setSubscriptionUser] = useState<any>(null);
const [recalculationToolsOpen, setRecalculationToolsOpen] = useState(false);
const loadUsers = async () => { const loadUsers = async () => {
setLoading(true); setLoading(true);
@@ -46,6 +53,15 @@ export const UsersPage = () => {
loadUsers(); loadUsers();
}, [page, filters.sortBy, filters.sortOrder, filters.isVerified]); }, [page, filters.sortBy, filters.sortOrder, filters.isVerified]);
useEffect(() => {
if (!openActionsUserId) return;
const closeActionsMenu = () => setOpenActionsUserId(null);
document.addEventListener('click', closeActionsMenu);
return () => document.removeEventListener('click', closeActionsMenu);
}, [openActionsUserId]);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setPage(1); setPage(1);
@@ -107,10 +123,23 @@ export const UsersPage = () => {
} }
}; };
const openRecalculationTools = (userId = '') => {
setRecalculationUserId(userId);
setRecalculationToolsOpen(true);
};
const runRowAction = (action: () => void) => {
setOpenActionsUserId(null);
action();
};
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="animate-fade-in users-page-container" style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexShrink: 0 }}>
<h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1> <h1 style={{ fontSize: '2rem', fontWeight: '700' }}>Gestión de Usuarios</h1>
<button className="btn-secondary" onClick={() => openRecalculationTools()} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<RefreshCw size={18} /> Recalcular
</button>
</div> </div>
{/* Filters Bar */} {/* Filters Bar */}
@@ -179,9 +208,9 @@ export const UsersPage = () => {
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr> <tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center' }}>Cargando...</td></tr>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<tr><td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr> <tr><td colSpan={6} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No se encontraron usuarios</td></tr>
) : ( ) : (
users.map(u => ( users.map(u => (
<tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover"> <tr key={u._id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)', transition: 'background 0.2s' }} className="table-row-hover">
@@ -205,13 +234,43 @@ export const UsersPage = () => {
{u.verificationCode || '-'} {u.verificationCode || '-'}
</td> </td>
<td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}> <td style={{ padding: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{new Date(u.creationDate).toLocaleDateString()} {formatDate(u.creationDate)}
</td> </td>
<td style={{ padding: '1rem', textAlign: 'right' }}> <td style={{ padding: '1rem', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}> <div className="actions-menu-wrapper" onClick={(e) => e.stopPropagation()}>
<button className="icon-btn" title="Ver Organizaciones" onClick={() => setOrgUser(u)}><Building2 size={18} /></button> <button
<button className="icon-btn" title="Editar Perfil" onClick={() => setEditingUser(u)}><Edit2 size={18} /></button> className="icon-btn"
<button className="icon-btn danger" title="Eliminar" onClick={() => handleDelete(u)}><Trash2 size={18} /></button> title="Acciones"
aria-label={`Acciones para ${u.email}`}
aria-haspopup="menu"
aria-expanded={openActionsUserId === u._id}
onClick={() => setOpenActionsUserId(openActionsUserId === u._id ? null : u._id)}
>
<MoreVertical size={18} />
</button>
{openActionsUserId === u._id && (
<div className="actions-menu" role="menu">
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setOrgUser(u))}>
<Building2 size={16} /> Ver organizaciones
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setSubscriptionUser(u))}>
<CreditCard size={16} /> Ver suscripción
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => openRecalculationTools(u._id))}>
<RefreshCw size={16} /> Recalcular uso del plan
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => handleToggleVerified(u))}>
{u.verificated ? <XCircle size={16} /> : <CheckCircle size={16} />}
{u.verificated ? 'Marcar como no verificado' : 'Marcar como verificado'}
</button>
<button type="button" className="actions-menu-item" role="menuitem" onClick={() => runRowAction(() => setEditingUser(u))}>
<Edit2 size={16} /> Editar perfil
</button>
<button type="button" className="actions-menu-item danger" role="menuitem" onClick={() => runRowAction(() => handleDelete(u))}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -261,6 +320,51 @@ export const UsersPage = () => {
.icon-btn.danger:hover { .icon-btn.danger:hover {
background: var(--danger); background: var(--danger);
} }
.actions-menu-wrapper {
position: relative;
display: inline-flex;
justify-content: flex-end;
}
.actions-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 30;
min-width: 230px;
padding: 0.4rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: #111827;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(12px);
}
.actions-menu-item {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 8px;
background: transparent;
color: #f8fafc;
font-size: 0.9rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: all 0.2s;
}
.actions-menu-item:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.actions-menu-item.danger {
color: #fca5a5;
}
.actions-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.18);
color: #fecaca;
}
.btn-secondary { .btn-secondary {
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
color: var(--text-main); color: var(--text-main);
@@ -293,6 +397,8 @@ export const UsersPage = () => {
{/* Modals */} {/* Modals */}
{editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />} {editingUser && <EditUserModal user={editingUser} onClose={() => setEditingUser(null)} onSaved={loadUsers} />}
{orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />} {orgUser && <OrganizationsModal user={orgUser} onClose={() => setOrgUser(null)} />}
{subscriptionUser && <SubscriptionDetailsModal user={subscriptionUser} onClose={() => setSubscriptionUser(null)} />}
{recalculationToolsOpen && <RecalculationToolsModal initialUserId={recalculationUserId} onClose={() => setRecalculationToolsOpen(false)} />}
</motion.div> </motion.div>
); );
}; };
+18
View File
@@ -0,0 +1,18 @@
import { format, isValid, parseISO } from 'date-fns';
import { es } from 'date-fns/locale';
const parseDate = (value?: string) => {
if (!value) return null;
const parsedDate = parseISO(value);
return isValid(parsedDate) ? parsedDate : null;
};
export const formatDate = (value?: string) => {
const parsedDate = parseDate(value);
return parsedDate ? format(parsedDate, 'dd MMM yyyy', { locale: es }) : value || '-';
};
export const formatDateTime = (value?: string) => {
const parsedDate = parseDate(value);
return parsedDate ? format(parsedDate, "dd MMM yyyy, HH:mm 'hs'", { locale: es }) : value || '-';
};
+2
View File
@@ -5,6 +5,7 @@ import wapRoutes from "./routes/wap.routes";
import usersRoutes from "./routes/users.routes"; import usersRoutes from "./routes/users.routes";
import companiesRoutes from "./routes/companies.routes"; import companiesRoutes from "./routes/companies.routes";
import servicesRoutes from "./routes/services.routes"; import servicesRoutes from "./routes/services.routes";
import plansRoutes from "./routes/plans.routes";
const app = express(); const app = express();
@@ -16,5 +17,6 @@ app.use("/wap", wapRoutes);
app.use("/users", usersRoutes); app.use("/users", usersRoutes);
app.use("/companies", companiesRoutes); app.use("/companies", companiesRoutes);
app.use("/services", servicesRoutes); app.use("/services", servicesRoutes);
app.use("/plans", plansRoutes);
export default app; export default app;
@@ -0,0 +1,28 @@
import { Request, Response } from "express";
import { PlansService } from "../services/plans.service";
import { SysAdminUpdatePlanParams } from "../models/Plans.Model";
const plansService = new PlansService();
export class PlansController {
public listPlans = async (_req: Request, res: Response): Promise<void> => {
try {
const plans = await plansService.listPlans();
res.json(plans);
} catch (error: any) {
console.error("Error listing plans:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public updatePlan = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminUpdatePlanParams = req.body;
const result = await plansService.updatePlan(data);
res.json(result);
} catch (error: any) {
console.error("Error updating plan:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
}
@@ -6,6 +6,9 @@ import {
SysAdminSetVerifiedParams, SysAdminSetVerifiedParams,
SysAdminDeleteUserParams, SysAdminDeleteUserParams,
SysAdminOrganizationsStatusParams, SysAdminOrganizationsStatusParams,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminExtendUserSubscriptionParams,
SysAdminUserSubscriptionDetailsParams,
} from "../models/Users.Model"; } from "../models/Users.Model";
const usersService = new UsersService(); const usersService = new UsersService();
@@ -65,4 +68,37 @@ export class UsersController {
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" }); res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
} }
}; };
public recalculatePlanUsageCycle = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminRecalculatePlanUsageCycleParams = req.body;
const result = await usersService.recalculatePlanUsageCycle(data);
res.json(result);
} catch (error: any) {
console.error("Error recalculating plan usage cycle:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public subscriptionDetails = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminUserSubscriptionDetailsParams = req.body;
const result = await usersService.getSubscriptionDetails(data);
res.json(result);
} catch (error: any) {
console.error("Error fetching subscription details:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
public extendSubscription = async (req: Request, res: Response): Promise<void> => {
try {
const data: SysAdminExtendUserSubscriptionParams = req.body;
const result = await usersService.extendSubscription(data);
res.json(result);
} catch (error: any) {
console.error("Error extending subscription:", error?.response?.data || error);
res.status(500).json(error?.response?.data || { success: false, message: "Internal Server Error" });
}
};
} }
+31
View File
@@ -0,0 +1,31 @@
export type SysAdminPlan = {
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 SysAdminUpdatePlanParams = Partial<Omit<SysAdminPlan, "id" | "_id">> & {
planId: string;
};
+64
View File
@@ -52,3 +52,67 @@ export type SysAdminUserOrganizationsResult = {
clientIn: { companyId: string; companyName: string }[]; clientIn: { companyId: string; companyName: string }[];
collaboratorIn: { companyId: string; companyName: string }[]; collaboratorIn: { companyId: string; companyName: string }[];
}; };
export type SysAdminRecalculatePlanUsageCycleParams = {
userId: string;
};
export type SysAdminRecalculatePlanUsageCycleResult = {
userId: string;
subscriptionId: string;
planId: string;
cycleStart: string;
cycleEnd: string;
appointmentsCount: number;
};
export type SysAdminUserSubscriptionDetailsParams = {
userId: string;
};
export type SysAdminExtendUserSubscriptionParams = {
userId: string;
mode: "add_months" | "set_end_date";
months?: 1 | 2;
endDate?: string;
reason?: string;
};
export type SysAdminExtendUserSubscriptionResult = {
userId: string;
subscriptionId: string;
previousEndDate: string;
newEndDate: string;
paymentId: string;
transactionId?: string;
};
export type SysAdminUserSubscriptionDetailsResult = {
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;
}[];
};
+10
View File
@@ -0,0 +1,10 @@
import { Router } from "express";
import { PlansController } from "../controllers/plans.controller";
const router = Router();
const plansController = new PlansController();
router.post("/list", plansController.listPlans);
router.post("/update", plansController.updatePlan);
export default router;
+3
View File
@@ -8,6 +8,9 @@ router.post("/paginate", usersController.paginateUsers);
router.post("/update", usersController.updateProfile); router.post("/update", usersController.updateProfile);
router.post("/set-verified", usersController.setVerifiedStatus); router.post("/set-verified", usersController.setVerifiedStatus);
router.post("/organizations-status", usersController.organizationsStatus); router.post("/organizations-status", usersController.organizationsStatus);
router.post("/recalculate-plan-usage-cycle", usersController.recalculatePlanUsageCycle);
router.post("/subscription-details", usersController.subscriptionDetails);
router.post("/extend-subscription", usersController.extendSubscription);
router.post("/delete", usersController.deleteUser); router.post("/delete", usersController.deleteUser);
export default router; export default router;
+31
View File
@@ -0,0 +1,31 @@
import axios from "axios";
import { getSysAdminNonce } from "../helpers/GetSysAdminNonce";
import { getApiHost } from "../helpers/GetApiHost";
import { getPayload } from "../helpers/GetPayload";
import { SysAdminPlan, SysAdminUpdatePlanParams } from "../models/Plans.Model";
export class PlansService {
public async listPlans(): Promise<SysAdminPlan[]> {
const nonce = await getSysAdminNonce();
const postData = { payload: getPayload(nonce) };
const response = await axios.post(`${getApiHost()}/sysadmin/plans/list`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return response.data;
}
public async updatePlan(data: SysAdminUpdatePlanParams): Promise<any> {
const nonce = await getSysAdminNonce();
const postData = { ...data, payload: getPayload(nonce) };
const response = await axios.post(`${getApiHost()}/sysadmin/plans/update`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 5000,
});
return response.data;
}
}
+48
View File
@@ -10,6 +10,12 @@ import {
SysAdminDeleteUserParams, SysAdminDeleteUserParams,
SysAdminOrganizationsStatusParams, SysAdminOrganizationsStatusParams,
SysAdminUserOrganizationsResult, SysAdminUserOrganizationsResult,
SysAdminRecalculatePlanUsageCycleParams,
SysAdminRecalculatePlanUsageCycleResult,
SysAdminExtendUserSubscriptionParams,
SysAdminExtendUserSubscriptionResult,
SysAdminUserSubscriptionDetailsParams,
SysAdminUserSubscriptionDetailsResult,
} from "../models/Users.Model"; } from "../models/Users.Model";
export class UsersService { export class UsersService {
@@ -72,4 +78,46 @@ export class UsersService {
return response.data; return response.data;
} }
public async recalculatePlanUsageCycle(
data: SysAdminRecalculatePlanUsageCycleParams
): Promise<SysAdminRecalculatePlanUsageCycleResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/plan-usage-cycle/recalculate-user`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
public async getSubscriptionDetails(
data: SysAdminUserSubscriptionDetailsParams
): Promise<SysAdminUserSubscriptionDetailsResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/user-details`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
public async extendSubscription(
data: SysAdminExtendUserSubscriptionParams
): Promise<SysAdminExtendUserSubscriptionResult> {
const nonce = await getSysAdminNonce();
const postData = { ...data, ...{ payload: getPayload(nonce) } };
const response = await axios.post(`${getApiHost()}/sysadmin/subscriptions/extend-user`, postData, {
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
return response.data;
}
} }