feat: implement organization insights dashboard with detailed metrics and collaboration data in sysadmin-cli

This commit is contained in:
2026-07-30 17:33:57 -03:00
parent 781310e36c
commit 146770a55d
14 changed files with 595 additions and 62 deletions
@@ -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>;
} }
+136
View File
@@ -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);
}
} }
+70
View File
@@ -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>
);
};
+33
View File
@@ -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;
}
}
+5 -60
View File
@@ -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 });
}
};
+69
View File
@@ -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;
};
};
+2 -1
View File
@@ -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;
};