feat: implement organization insights dashboard with detailed metrics and collaboration data in sysadmin-cli
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -264,6 +264,75 @@ export type SysAdminSetCompanyBannedParams = {
|
|||||||
banned: boolean;
|
banned: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SysAdminCompanyInsightsParams = {
|
||||||
|
companyId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SysAdminCompanyInsightsResult = {
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
categoryId: number;
|
||||||
|
banned: boolean;
|
||||||
|
published?: COMPANY_PUBLISHED_STATUS;
|
||||||
|
onboardingStep?: number;
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
|
};
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
verificated?: boolean;
|
||||||
|
phoneCountryCode?: string;
|
||||||
|
phoneAreaCode?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
} | null;
|
||||||
|
collaborators: {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
roles?: string[];
|
||||||
|
removed?: boolean;
|
||||||
|
active: boolean;
|
||||||
|
avatar?: string;
|
||||||
|
}[];
|
||||||
|
subscription: {
|
||||||
|
id: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
autoRenew: boolean;
|
||||||
|
mpStatus?: string;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
plan: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
limitOrganizations: number;
|
||||||
|
limitEmployees: number;
|
||||||
|
limitServices: number;
|
||||||
|
limitAppointments: number;
|
||||||
|
limitClients: number;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
stats: {
|
||||||
|
employeesCount: number;
|
||||||
|
activeEmployeesCount: number;
|
||||||
|
servicesCount: number;
|
||||||
|
activeServicesCount: number;
|
||||||
|
clientsCount: number;
|
||||||
|
activeClientsCount: number;
|
||||||
|
reservationsLast30Days: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type FixCompanyPostParams = {
|
export type FixCompanyPostParams = {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
postId: string;
|
postId: string;
|
||||||
@@ -310,4 +379,5 @@ export interface ICompaniesManager {
|
|||||||
sysAdminPaginate(data: SysAdminPaginateCompaniesParams): Promise<PaginateCompaniesResults>;
|
sysAdminPaginate(data: SysAdminPaginateCompaniesParams): Promise<PaginateCompaniesResults>;
|
||||||
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
|
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
|
||||||
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
|
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
|
||||||
|
sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
SysAdminPaginateCompaniesParams,
|
SysAdminPaginateCompaniesParams,
|
||||||
SysAdminUpdateCompanyParams,
|
SysAdminUpdateCompanyParams,
|
||||||
SysAdminSetCompanyBannedParams,
|
SysAdminSetCompanyBannedParams,
|
||||||
|
SysAdminCompanyInsightsParams,
|
||||||
|
SysAdminCompanyInsightsResult,
|
||||||
} from "./Companies.Interface";
|
} from "./Companies.Interface";
|
||||||
import UsersManager from "../Users/Users";
|
import UsersManager from "../Users/Users";
|
||||||
import EmployeesList from "../Employees/Employee";
|
import EmployeesList from "../Employees/Employee";
|
||||||
@@ -54,11 +56,14 @@ import ClientAccount from "../ClientAccounts/ClientAccount";
|
|||||||
import Payments from "../Payments/Payments";
|
import Payments from "../Payments/Payments";
|
||||||
import Discounts from "../Discounts/Discounts";
|
import Discounts from "../Discounts/Discounts";
|
||||||
import Templates from "../Templates/Templates";
|
import Templates from "../Templates/Templates";
|
||||||
|
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||||
|
import PlansList from "../Plans/Plans";
|
||||||
|
|
||||||
import Posts from "../Posts/Posts";
|
import Posts from "../Posts/Posts";
|
||||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||||
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
|
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
|
||||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||||
|
import getAvatar from "../../helpers/getAvatar";
|
||||||
|
|
||||||
class CompaniesManager implements ICompaniesManager {
|
class CompaniesManager implements ICompaniesManager {
|
||||||
companies: ICompaniesAdapter;
|
companies: ICompaniesAdapter;
|
||||||
@@ -1085,6 +1090,137 @@ class CompaniesManager implements ICompaniesManager {
|
|||||||
sessionUser: "" // sysadmin override
|
sessionUser: "" // sysadmin override
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult> {
|
||||||
|
const company = await this.companies.findOne({ _id: data.companyId });
|
||||||
|
if (!company) {
|
||||||
|
throw new Error("La compañia no existe");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ownerResult, subscriptionResult] = await Promise.allSettled([
|
||||||
|
UsersManager.users.findOne({ _id: String(company.ownerId) }),
|
||||||
|
PlanSubscriptionsList.planSuscriptions.planSuscriptionList
|
||||||
|
.findOne({ userId: String(company.ownerId) })
|
||||||
|
.sort({ endDate: -1 })
|
||||||
|
.exec(),
|
||||||
|
]);
|
||||||
|
const owner = ownerResult.status === "fulfilled" ? ownerResult.value : null;
|
||||||
|
const subscription = subscriptionResult.status === "fulfilled" ? subscriptionResult.value : null;
|
||||||
|
const collaboratorsResult = await Promise.allSettled([
|
||||||
|
EmployeesList.employees.employeeList.find({ companyId: data.companyId }).exec(),
|
||||||
|
]);
|
||||||
|
const employeeDocs = collaboratorsResult[0]?.status === "fulfilled" ? collaboratorsResult[0].value : [];
|
||||||
|
const collaboratorUserResults = await Promise.allSettled(
|
||||||
|
employeeDocs.map((employee) => employee.userId
|
||||||
|
? UsersManager.users.findOne({ _id: String(employee.userId) })
|
||||||
|
: Promise.resolve(null))
|
||||||
|
);
|
||||||
|
const collaborators = employeeDocs.map((employee, index) => {
|
||||||
|
const userResult = collaboratorUserResults[index];
|
||||||
|
const user = userResult?.status === "fulfilled" ? userResult.value : null;
|
||||||
|
const firstName = user?.firstName || employee.profileSnapshot?.firstName;
|
||||||
|
const lastName = user?.lastName || employee.profileSnapshot?.lastName;
|
||||||
|
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
||||||
|
const avatar = user?.avatar
|
||||||
|
? getAvatar(String(user.id || user._id), user.avatar, fullName)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(employee.id || employee._id),
|
||||||
|
userId: employee.userId ? String(employee.userId) : undefined,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
fullName: fullName || undefined,
|
||||||
|
email: user?.email || employee.profileSnapshot?.email,
|
||||||
|
roles: employee.roles || [],
|
||||||
|
removed: employee.removed ?? false,
|
||||||
|
active: !(employee.removed ?? false),
|
||||||
|
avatar: avatar !== fullName ? avatar : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const planResult = subscription
|
||||||
|
? await Promise.allSettled([PlansList.plans.planList.findOne({ _id: String(subscription.planId) }).exec()])
|
||||||
|
: [];
|
||||||
|
const plan = planResult[0]?.status === "fulfilled" ? planResult[0].value : null;
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
|
||||||
|
thirtyDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [
|
||||||
|
employeesCount,
|
||||||
|
activeEmployeesCount,
|
||||||
|
servicesCount,
|
||||||
|
activeServicesCount,
|
||||||
|
clientsCount,
|
||||||
|
activeClientsCount,
|
||||||
|
reservationsLast30Days,
|
||||||
|
] = (await Promise.allSettled([
|
||||||
|
EmployeesList.employees.employeeList.countDocuments({ companyId: data.companyId }).exec(),
|
||||||
|
EmployeesList.employees.employeeList.countDocuments({ companyId: data.companyId, removed: { $ne: true } }).exec(),
|
||||||
|
ServiceList.services.serviceList.countDocuments({ companyId: data.companyId }).exec(),
|
||||||
|
ServiceList.services.serviceList.countDocuments({ companyId: data.companyId, banned: { $ne: true } }).exec(),
|
||||||
|
ClientsList.clients.clientList.countDocuments({ companyId: data.companyId }).exec(),
|
||||||
|
ClientsList.clients.clientList.countDocuments({ companyId: data.companyId, status: true, banned: { $ne: true } }).exec(),
|
||||||
|
AppointmentList.Appointments.AppointmentList.countDocuments({
|
||||||
|
companyId: data.companyId,
|
||||||
|
start: { $gte: thirtyDaysAgo },
|
||||||
|
}).exec(),
|
||||||
|
])).map((result) => result.status === "fulfilled" ? result.value : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: {
|
||||||
|
id: String(company.id || company._id),
|
||||||
|
name: company.name,
|
||||||
|
slug: company.slug,
|
||||||
|
description: company.description,
|
||||||
|
categoryId: company.categoryId,
|
||||||
|
banned: company.banned ?? false,
|
||||||
|
published: company.published,
|
||||||
|
onboardingStep: company.onboardingStep,
|
||||||
|
onboardingCompleted: company.onboardingCompleted,
|
||||||
|
},
|
||||||
|
owner: owner ? {
|
||||||
|
id: String(owner.id || owner._id),
|
||||||
|
firstName: owner.firstName,
|
||||||
|
lastName: owner.lastName,
|
||||||
|
email: owner.email,
|
||||||
|
verificated: owner.verificated,
|
||||||
|
phoneCountryCode: owner.phoneCountryCode,
|
||||||
|
phoneAreaCode: owner.phoneAreaCode,
|
||||||
|
phoneNumber: owner.phoneNumber,
|
||||||
|
} : null,
|
||||||
|
collaborators,
|
||||||
|
subscription: subscription ? {
|
||||||
|
id: String(subscription.id || subscription._id),
|
||||||
|
startDate: subscription.startDate,
|
||||||
|
endDate: subscription.endDate,
|
||||||
|
isActive: subscription.isActive,
|
||||||
|
autoRenew: subscription.autoRenew,
|
||||||
|
mpStatus: subscription.mpStatus,
|
||||||
|
pendingPaymentType: subscription.pendingPaymentType,
|
||||||
|
plan: plan ? {
|
||||||
|
id: String(plan.id || plan._id),
|
||||||
|
name: plan.name,
|
||||||
|
code: plan.code,
|
||||||
|
price: plan.price,
|
||||||
|
limitOrganizations: plan.limitOrganizations,
|
||||||
|
limitEmployees: plan.limitEmployees,
|
||||||
|
limitServices: plan.limitServices,
|
||||||
|
limitAppointments: plan.limitAppointments,
|
||||||
|
limitClients: plan.limitClients,
|
||||||
|
} : null,
|
||||||
|
} : null,
|
||||||
|
stats: {
|
||||||
|
employeesCount,
|
||||||
|
activeEmployeesCount,
|
||||||
|
servicesCount,
|
||||||
|
activeServicesCount,
|
||||||
|
clientsCount,
|
||||||
|
activeClientsCount,
|
||||||
|
reservationsLast30Days,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CompaniesList = new CompaniesManager();
|
const CompaniesList = new CompaniesManager();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
SysAdminPaginateCompaniesParams,
|
SysAdminPaginateCompaniesParams,
|
||||||
SysAdminUpdateCompanyParams,
|
SysAdminUpdateCompanyParams,
|
||||||
SysAdminSetCompanyBannedParams,
|
SysAdminSetCompanyBannedParams,
|
||||||
|
SysAdminCompanyInsightsParams,
|
||||||
|
SysAdminCompanyInsightsResult,
|
||||||
} from "../../../Models/Companies/Companies.Interface";
|
} from "../../../Models/Companies/Companies.Interface";
|
||||||
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
import { ApiValidationError } from "../../../Models/Server.Error.model";
|
||||||
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
|
import { ApiVoidResult } from "../../../Models/Api.VoidResult.type";
|
||||||
@@ -66,4 +68,23 @@ export class SysAdminCompaniesController extends Controller {
|
|||||||
return new ApiValidationError(500, errorOccurred.message);
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post("insights")
|
||||||
|
@Middlewares(sysAdminAuthMiddleware)
|
||||||
|
public async sysAdminGetInsights(
|
||||||
|
@Body() requestBody: SysAdminCompanyInsightsParams,
|
||||||
|
): Promise<SysAdminCompanyInsightsResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const { payload, ...cleanRequestBody } = requestBody as any;
|
||||||
|
const result = await new SA_CompaniesService().sysAdminGetInsights(cleanRequestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
SysAdminPaginateCompaniesParams,
|
SysAdminPaginateCompaniesParams,
|
||||||
SysAdminUpdateCompanyParams,
|
SysAdminUpdateCompanyParams,
|
||||||
SysAdminSetCompanyBannedParams,
|
SysAdminSetCompanyBannedParams,
|
||||||
|
SysAdminCompanyInsightsParams,
|
||||||
|
SysAdminCompanyInsightsResult,
|
||||||
} from "../../../Models/Companies/Companies.Interface";
|
} from "../../../Models/Companies/Companies.Interface";
|
||||||
import CompaniesList from "../../../Models/Companies/Companies";
|
import CompaniesList from "../../../Models/Companies/Companies";
|
||||||
|
|
||||||
@@ -22,4 +24,9 @@ export class SA_CompaniesService {
|
|||||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
return await CompaniesList.sysAdminSetBanned(data);
|
return await CompaniesList.sysAdminSetBanned(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
return await CompaniesList.sysAdminGetInsights(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,76 @@ export const setCompanyBanned = async (id: string, banned: boolean) => {
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrganizationInsights = {
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
categoryId: number;
|
||||||
|
banned: boolean;
|
||||||
|
published?: string;
|
||||||
|
onboardingStep?: number;
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
|
};
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
verificated?: boolean;
|
||||||
|
phoneCountryCode?: string;
|
||||||
|
phoneAreaCode?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
} | null;
|
||||||
|
collaborators: {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
roles?: string[];
|
||||||
|
removed?: boolean;
|
||||||
|
active: boolean;
|
||||||
|
avatar?: string;
|
||||||
|
}[];
|
||||||
|
subscription: {
|
||||||
|
id: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
isActive: boolean;
|
||||||
|
autoRenew: boolean;
|
||||||
|
mpStatus?: string;
|
||||||
|
pendingPaymentType?: 'extension' | 'upgrade';
|
||||||
|
plan: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
limitOrganizations: number;
|
||||||
|
limitEmployees: number;
|
||||||
|
limitServices: number;
|
||||||
|
limitAppointments: number;
|
||||||
|
limitClients: number;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
stats: {
|
||||||
|
employeesCount: number;
|
||||||
|
activeEmployeesCount: number;
|
||||||
|
servicesCount: number;
|
||||||
|
activeServicesCount: number;
|
||||||
|
clientsCount: number;
|
||||||
|
activeClientsCount: number;
|
||||||
|
reservationsLast30Days: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchOrganizationInsights = async (companyId: string): Promise<OrganizationInsights> => {
|
||||||
|
const res = await sysadminApi.post('/companies/insights', { companyId });
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
// Services API Calls
|
// Services API Calls
|
||||||
export const fetchServices = async (filters: any) => {
|
export const fetchServices = async (filters: any) => {
|
||||||
const res = await sysadminApi.post('/services/paginate', filters);
|
const res = await sysadminApi.post('/services/paginate', filters);
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { AlertCircle, CalendarDays, CheckCircle2, Users, X } from 'lucide-react';
|
||||||
|
import { fetchOrganizationInsights } from '../../api/sysadmin';
|
||||||
|
import type { OrganizationInsights } from '../../api/sysadmin';
|
||||||
|
|
||||||
|
interface OrganizationInsightsModalProps {
|
||||||
|
company: any;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
return new Intl.DateTimeFormat('es-AR', { dateStyle: 'medium' }).format(new Date(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullName = (owner: OrganizationInsights['owner']) => {
|
||||||
|
if (!owner) return '-';
|
||||||
|
return [owner.firstName, owner.lastName].filter(Boolean).join(' ') || owner.email || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const phone = (owner: OrganizationInsights['owner']) => {
|
||||||
|
if (!owner) return '-';
|
||||||
|
return [owner.phoneCountryCode, owner.phoneAreaCode, owner.phoneNumber].filter(Boolean).join(' ') || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const collaboratorName = (collaborator: OrganizationInsights['collaborators'][number]) => {
|
||||||
|
return collaborator.fullName || [collaborator.firstName, collaborator.lastName].filter(Boolean).join(' ') || collaborator.email || collaborator.userId || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatCard = ({ label, value }: { label: string; value: number }) => (
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1rem' }}>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginBottom: '0.35rem' }}>{label}</div>
|
||||||
|
<div style={{ color: 'var(--text-main)', fontSize: '1.6rem', fontWeight: 700 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OrganizationInsightsModal: React.FC<OrganizationInsightsModalProps> = ({ company, onClose }) => {
|
||||||
|
const [insights, setInsights] = useState<OrganizationInsights | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadInsights = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await fetchOrganizationInsights(company.id || company._id);
|
||||||
|
setInsights(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError('No se pudo cargar el detalle de la organización.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInsights();
|
||||||
|
}, [company]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="glass-panel"
|
||||||
|
style={{ width: '92%', maxWidth: '900px', padding: '2rem', position: 'relative', display: 'flex', flexDirection: 'column', maxHeight: '85vh', overflow: 'auto' }}
|
||||||
|
>
|
||||||
|
<button onClick={onClose} style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'transparent', color: 'var(--text-muted)' }}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '0.35rem', fontWeight: 'bold' }}>Detalle de {company.name}</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Vista de solo lectura para SysAdmin.</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>Cargando detalle...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', color: 'var(--danger)', padding: '1rem', border: '1px solid rgba(255,0,0,0.25)', borderRadius: '12px' }}>
|
||||||
|
<AlertCircle size={18} /> {error}
|
||||||
|
</div>
|
||||||
|
) : !insights ? (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>No hay información disponible.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
|
||||||
|
<StatCard label="Colaboradores" value={insights.stats.employeesCount} />
|
||||||
|
<StatCard label="Colaboradores activos" value={insights.stats.activeEmployeesCount} />
|
||||||
|
<StatCard label="Servicios" value={insights.stats.servicesCount} />
|
||||||
|
<StatCard label="Servicios activos" value={insights.stats.activeServicesCount} />
|
||||||
|
<StatCard label="Clientes" value={insights.stats.clientsCount} />
|
||||||
|
<StatCard label="Clientes activos" value={insights.stats.activeClientsCount} />
|
||||||
|
<StatCard label="Reservas últimos 30 días" value={insights.stats.reservationsLast30Days} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1rem' }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1rem', marginBottom: '1rem' }}><CheckCircle2 size={18} /> Organización</h3>
|
||||||
|
<p><strong>Nombre:</strong> {insights.organization.name}</p>
|
||||||
|
<p><strong>Slug:</strong> {insights.organization.slug || '-'}</p>
|
||||||
|
<p><strong>Estado:</strong> {insights.organization.banned ? 'Baneada' : 'Activa'}</p>
|
||||||
|
<p><strong>Publicación:</strong> {insights.organization.published || '-'}</p>
|
||||||
|
<p><strong>Onboarding:</strong> {insights.organization.onboardingCompleted ? 'Completo' : `Paso ${insights.organization.onboardingStep || 0}`}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1rem' }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1rem', marginBottom: '1rem' }}><Users size={18} /> Dueño</h3>
|
||||||
|
<p><strong>Nombre:</strong> {fullName(insights.owner)}</p>
|
||||||
|
<p><strong>Email:</strong> {insights.owner?.email || '-'}</p>
|
||||||
|
<p><strong>Teléfono:</strong> {phone(insights.owner)}</p>
|
||||||
|
<p><strong>Verificado:</strong> {insights.owner?.verificated ? 'Sí' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1rem' }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1rem', marginBottom: '1rem' }}><CalendarDays size={18} /> Plan</h3>
|
||||||
|
{insights.subscription ? (
|
||||||
|
<>
|
||||||
|
<p><strong>Plan:</strong> {insights.subscription.plan?.name || 'Sin plan asociado'}</p>
|
||||||
|
<p><strong>Código:</strong> {insights.subscription.plan?.code || '-'}</p>
|
||||||
|
<p><strong>Estado:</strong> {insights.subscription.mpStatus || (insights.subscription.isActive ? 'active' : 'inactive')}</p>
|
||||||
|
<p><strong>Inicio:</strong> {formatDate(insights.subscription.startDate)}</p>
|
||||||
|
<p><strong>Fin:</strong> {formatDate(insights.subscription.endDate)}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No se encontró suscripción.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1rem' }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1rem', marginBottom: '1rem' }}><Users size={18} /> Colaboradores</h3>
|
||||||
|
{insights.collaborators.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No hay colaboradores asociados.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||||
|
{insights.collaborators.map((collaborator) => (
|
||||||
|
<div key={collaborator.id} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: '0.75rem', alignItems: 'center', padding: '0.75rem', border: '1px solid var(--glass-border)', borderRadius: '10px' }}>
|
||||||
|
<div style={{ width: '36px', height: '36px', borderRadius: '999px', overflow: 'hidden', background: 'rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontWeight: 700 }}>
|
||||||
|
{collaborator.avatar ? <img src={collaborator.avatar} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : collaboratorName(collaborator).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-main)', fontWeight: 600 }}>{collaboratorName(collaborator)}</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{collaborator.email || 'Sin email disponible'}</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>Roles: {collaborator.roles?.join(', ') || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: collaborator.active ? 'var(--success)' : 'var(--text-muted)', fontSize: '0.82rem', fontWeight: 700 }}>
|
||||||
|
{collaborator.active ? 'Activo' : 'Removido'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -160,3 +160,36 @@ button {
|
|||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fadeIn 0.4s ease-out forwards;
|
animation: fadeIn 0.4s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-header:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-hover:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.companies-page-container {
|
||||||
|
height: auto !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-bar form > div {
|
||||||
|
flex: 1 1 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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, ShieldAlert, ShieldCheck, ChevronUp, ChevronDown, RotateCcw, Box } from 'lucide-react';
|
import { Search, Edit2, ShieldAlert, ShieldCheck, ChevronUp, ChevronDown, RotateCcw, Box, Eye } from 'lucide-react';
|
||||||
import { fetchCompanies, setCompanyBanned } from '../api/sysadmin';
|
import { fetchCompanies, setCompanyBanned } from '../api/sysadmin';
|
||||||
import { EditCompanyModal } from '../components/ui/EditCompanyModal';
|
import { EditCompanyModal } from '../components/ui/EditCompanyModal';
|
||||||
import { CompanyServicesModal } from '../components/ui/CompanyServicesModal';
|
import { CompanyServicesModal } from '../components/ui/CompanyServicesModal';
|
||||||
|
import { OrganizationInsightsModal } from '../components/ui/OrganizationInsightsModal';
|
||||||
|
|
||||||
export const CompaniesPage = () => {
|
export const CompaniesPage = () => {
|
||||||
const [companies, setCompanies] = useState<any[]>([]);
|
const [companies, setCompanies] = useState<any[]>([]);
|
||||||
@@ -22,6 +23,7 @@ export const CompaniesPage = () => {
|
|||||||
// Modals state
|
// Modals state
|
||||||
const [editingCompany, setEditingCompany] = useState<any>(null);
|
const [editingCompany, setEditingCompany] = useState<any>(null);
|
||||||
const [servicesCompany, setServicesCompany] = useState<any>(null);
|
const [servicesCompany, setServicesCompany] = useState<any>(null);
|
||||||
|
const [insightsCompany, setInsightsCompany] = useState<any>(null);
|
||||||
|
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -176,6 +178,7 @@ export const CompaniesPage = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'right' }}>
|
<td style={{ padding: '1rem', textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="icon-btn" title="Ver detalle" onClick={() => setInsightsCompany(c)}><Eye size={18} /></button>
|
||||||
<button className="icon-btn" title="Ver Servicios" onClick={() => setServicesCompany(c)}><Box size={18} /></button>
|
<button className="icon-btn" title="Ver Servicios" onClick={() => setServicesCompany(c)}><Box size={18} /></button>
|
||||||
<button className="icon-btn" title="Editar Detalles" onClick={() => setEditingCompany(c)}><Edit2 size={18} /></button>
|
<button className="icon-btn" title="Editar Detalles" onClick={() => setEditingCompany(c)}><Edit2 size={18} /></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,68 +200,10 @@ export const CompaniesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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);
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.sortable-header:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
.table-row-hover:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
.icon-btn {
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.icon-btn:hover {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.icon-btn.danger:hover {
|
|
||||||
background: var(--danger);
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
color: var(--text-main);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
.btn-secondary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.companies-page-container {
|
|
||||||
height: auto !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
.filters-bar form > div {
|
|
||||||
flex: 1 1 100% !important;
|
|
||||||
}
|
|
||||||
.table-scroll-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}} />
|
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{editingCompany && <EditCompanyModal company={editingCompany} onClose={() => setEditingCompany(null)} onSaved={loadCompanies} />}
|
{editingCompany && <EditCompanyModal company={editingCompany} onClose={() => setEditingCompany(null)} onSaved={loadCompanies} />}
|
||||||
{servicesCompany && <CompanyServicesModal company={servicesCompany} onClose={() => setServicesCompany(null)} />}
|
{servicesCompany && <CompanyServicesModal company={servicesCompany} onClose={() => setServicesCompany(null)} />}
|
||||||
|
{insightsCompany && <OrganizationInsightsModal company={insightsCompany} onClose={() => setInsightsCompany(null)} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { paginateCompanies, setCompanyBanned, updateCompany } from "../services/companies.service";
|
import { getOrganizationInsights, paginateCompanies, setCompanyBanned, updateCompany } from "../services/companies.service";
|
||||||
|
|
||||||
export const paginate = async (req: Request, res: Response) => {
|
export const paginate = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -33,3 +33,14 @@ export const setBanned = async (req: Request, res: Response) => {
|
|||||||
res.status(500).json({ error: err });
|
res.status(500).json({ error: err });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const insights = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await getOrganizationInsights(req.body);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(e);
|
||||||
|
const err = e.response ? e.response.data : e.message;
|
||||||
|
res.status(500).json({ error: err });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -35,3 +35,72 @@ export type SetCompanyBannedParams = {
|
|||||||
id: string;
|
id: string;
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrganizationInsightsParams = {
|
||||||
|
companyId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationInsightsResult = {
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
categoryId: number;
|
||||||
|
banned: boolean;
|
||||||
|
published?: string;
|
||||||
|
onboardingStep?: number;
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
|
};
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
verificated?: boolean;
|
||||||
|
phoneCountryCode?: string;
|
||||||
|
phoneAreaCode?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
} | null;
|
||||||
|
collaborators: {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
roles?: string[];
|
||||||
|
removed?: boolean;
|
||||||
|
active: boolean;
|
||||||
|
avatar?: string;
|
||||||
|
}[];
|
||||||
|
subscription: {
|
||||||
|
id: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
isActive: boolean;
|
||||||
|
autoRenew: boolean;
|
||||||
|
mpStatus?: string;
|
||||||
|
pendingPaymentType?: "extension" | "upgrade";
|
||||||
|
plan: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
price: number;
|
||||||
|
limitOrganizations: number;
|
||||||
|
limitEmployees: number;
|
||||||
|
limitServices: number;
|
||||||
|
limitAppointments: number;
|
||||||
|
limitClients: number;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
stats: {
|
||||||
|
employeesCount: number;
|
||||||
|
activeEmployeesCount: number;
|
||||||
|
servicesCount: number;
|
||||||
|
activeServicesCount: number;
|
||||||
|
clientsCount: number;
|
||||||
|
activeClientsCount: number;
|
||||||
|
reservationsLast30Days: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { paginate, setBanned, update } from "../controllers/companies.controller";
|
import { insights, paginate, setBanned, update } from "../controllers/companies.controller";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post("/paginate", paginate);
|
router.post("/paginate", paginate);
|
||||||
router.post("/update", update);
|
router.post("/update", update);
|
||||||
router.post("/set-banned", setBanned);
|
router.post("/set-banned", setBanned);
|
||||||
|
router.post("/insights", insights);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getApiHost } from "../helpers/GetApiHost";
|
|||||||
import {
|
import {
|
||||||
PaginateCompaniesParams,
|
PaginateCompaniesParams,
|
||||||
PaginateCompaniesResults,
|
PaginateCompaniesResults,
|
||||||
|
OrganizationInsightsParams,
|
||||||
|
OrganizationInsightsResult,
|
||||||
SetCompanyBannedParams,
|
SetCompanyBannedParams,
|
||||||
UpdateCompanyParams,
|
UpdateCompanyParams,
|
||||||
} from "../models/Companies.Model";
|
} from "../models/Companies.Model";
|
||||||
@@ -35,3 +37,12 @@ export const setCompanyBanned = async (params: SetCompanyBannedParams): Promise<
|
|||||||
});
|
});
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getOrganizationInsights = async (params: OrganizationInsightsParams): Promise<OrganizationInsightsResult> => {
|
||||||
|
const nonce = await getSysAdminNonce();
|
||||||
|
const result = await axios.post(`${getApiHost()}/sysadmin/companies/insights`, {
|
||||||
|
...params,
|
||||||
|
payload: getPayload(nonce),
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user