feat: implement sysadmin subscription management and plan usage cycle recalculation tools
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 || '-';
|
||||||
|
};
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user