diff --git a/resources/triptico/turnosxpress-triptico-interno.afdesign b/resources/triptico/turnosxpress-triptico-interno.afdesign index 9cc1f55..49e87e3 100644 Binary files a/resources/triptico/turnosxpress-triptico-interno.afdesign and b/resources/triptico/turnosxpress-triptico-interno.afdesign differ diff --git a/resources/triptico/turnosxpress-triptico-interno.afdesign~lock~ b/resources/triptico/turnosxpress-triptico-interno.afdesign~lock~ new file mode 100644 index 0000000..6f13bf8 Binary files /dev/null and b/resources/triptico/turnosxpress-triptico-interno.afdesign~lock~ differ diff --git a/server/src/Models/Companies/Companies.Interface.ts b/server/src/Models/Companies/Companies.Interface.ts index 57493a7..541c8a0 100644 --- a/server/src/Models/Companies/Companies.Interface.ts +++ b/server/src/Models/Companies/Companies.Interface.ts @@ -264,6 +264,75 @@ export type SysAdminSetCompanyBannedParams = { 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 = { companyId: string; postId: string; @@ -310,4 +379,5 @@ export interface ICompaniesManager { sysAdminPaginate(data: SysAdminPaginateCompaniesParams): Promise; sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise; sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise; + sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise; } diff --git a/server/src/Models/Companies/Companies.ts b/server/src/Models/Companies/Companies.ts index f852b0b..618319b 100644 --- a/server/src/Models/Companies/Companies.ts +++ b/server/src/Models/Companies/Companies.ts @@ -27,6 +27,8 @@ import { SysAdminPaginateCompaniesParams, SysAdminUpdateCompanyParams, SysAdminSetCompanyBannedParams, + SysAdminCompanyInsightsParams, + SysAdminCompanyInsightsResult, } from "./Companies.Interface"; import UsersManager from "../Users/Users"; import EmployeesList from "../Employees/Employee"; @@ -54,11 +56,14 @@ import ClientAccount from "../ClientAccounts/ClientAccount"; import Payments from "../Payments/Payments"; import Discounts from "../Discounts/Discounts"; import Templates from "../Templates/Templates"; +import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons"; +import PlansList from "../Plans/Plans"; import Posts from "../Posts/Posts"; import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled"; import DiscountsEmail from "../DiscountsEmail/DiscountsEmail"; import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; +import getAvatar from "../../helpers/getAvatar"; class CompaniesManager implements ICompaniesManager { companies: ICompaniesAdapter; @@ -1085,6 +1090,137 @@ class CompaniesManager implements ICompaniesManager { sessionUser: "" // sysadmin override }); } + + public async sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise { + 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(); diff --git a/server/src/api/SysAdmin/Companies/SysAdminCompanies.Controller.ts b/server/src/api/SysAdmin/Companies/SysAdminCompanies.Controller.ts index be9edd8..b9a989a 100644 --- a/server/src/api/SysAdmin/Companies/SysAdminCompanies.Controller.ts +++ b/server/src/api/SysAdmin/Companies/SysAdminCompanies.Controller.ts @@ -3,6 +3,8 @@ import { SysAdminPaginateCompaniesParams, SysAdminUpdateCompanyParams, SysAdminSetCompanyBannedParams, + SysAdminCompanyInsightsParams, + SysAdminCompanyInsightsResult, } from "../../../Models/Companies/Companies.Interface"; import { ApiValidationError } from "../../../Models/Server.Error.model"; import { ApiVoidResult } from "../../../Models/Api.VoidResult.type"; @@ -66,4 +68,23 @@ export class SysAdminCompaniesController extends Controller { return new ApiValidationError(500, errorOccurred.message); } } + + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post("insights") + @Middlewares(sysAdminAuthMiddleware) + public async sysAdminGetInsights( + @Body() requestBody: SysAdminCompanyInsightsParams, + ): Promise { + 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); + } + } } diff --git a/server/src/api/SysAdmin/Companies/SysAdminCompanies.Service.ts b/server/src/api/SysAdmin/Companies/SysAdminCompanies.Service.ts index 1247bbe..cfcab43 100644 --- a/server/src/api/SysAdmin/Companies/SysAdminCompanies.Service.ts +++ b/server/src/api/SysAdmin/Companies/SysAdminCompanies.Service.ts @@ -4,6 +4,8 @@ import { SysAdminPaginateCompaniesParams, SysAdminUpdateCompanyParams, SysAdminSetCompanyBannedParams, + SysAdminCompanyInsightsParams, + SysAdminCompanyInsightsResult, } from "../../../Models/Companies/Companies.Interface"; import CompaniesList from "../../../Models/Companies/Companies"; @@ -22,4 +24,9 @@ export class SA_CompaniesService { await connect(`${process.env.DATABASE_CONNECTION}`); return await CompaniesList.sysAdminSetBanned(data); } + + public async sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await CompaniesList.sysAdminGetInsights(data); + } } diff --git a/sysadmin-cli/src/api/sysadmin.ts b/sysadmin-cli/src/api/sysadmin.ts index 8b51a13..c80ff5f 100644 --- a/sysadmin-cli/src/api/sysadmin.ts +++ b/sysadmin-cli/src/api/sysadmin.ts @@ -314,6 +314,76 @@ export const setCompanyBanned = async (id: string, banned: boolean) => { 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 => { + const res = await sysadminApi.post('/companies/insights', { companyId }); + return res.data; +}; + // Services API Calls export const fetchServices = async (filters: any) => { const res = await sysadminApi.post('/services/paginate', filters); diff --git a/sysadmin-cli/src/components/ui/OrganizationInsightsModal.tsx b/sysadmin-cli/src/components/ui/OrganizationInsightsModal.tsx new file mode 100644 index 0000000..1bef49d --- /dev/null +++ b/sysadmin-cli/src/components/ui/OrganizationInsightsModal.tsx @@ -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 }) => ( +
+
{label}
+
{value}
+
+); + +export const OrganizationInsightsModal: React.FC = ({ company, onClose }) => { + const [insights, setInsights] = useState(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 ( +
+ + + +

Detalle de {company.name}

+

Vista de solo lectura para SysAdmin.

+ + {loading ? ( +
Cargando detalle...
+ ) : error ? ( +
+ {error} +
+ ) : !insights ? ( +
No hay información disponible.
+ ) : ( +
+
+ + + + + + + +
+ +
+
+

Organización

+

Nombre: {insights.organization.name}

+

Slug: {insights.organization.slug || '-'}

+

Estado: {insights.organization.banned ? 'Baneada' : 'Activa'}

+

Publicación: {insights.organization.published || '-'}

+

Onboarding: {insights.organization.onboardingCompleted ? 'Completo' : `Paso ${insights.organization.onboardingStep || 0}`}

+
+ +
+

Dueño

+

Nombre: {fullName(insights.owner)}

+

Email: {insights.owner?.email || '-'}

+

Teléfono: {phone(insights.owner)}

+

Verificado: {insights.owner?.verificated ? 'Sí' : 'No'}

+
+ +
+

Plan

+ {insights.subscription ? ( + <> +

Plan: {insights.subscription.plan?.name || 'Sin plan asociado'}

+

Código: {insights.subscription.plan?.code || '-'}

+

Estado: {insights.subscription.mpStatus || (insights.subscription.isActive ? 'active' : 'inactive')}

+

Inicio: {formatDate(insights.subscription.startDate)}

+

Fin: {formatDate(insights.subscription.endDate)}

+ + ) : ( +

No se encontró suscripción.

+ )} +
+
+ +
+

Colaboradores

+ {insights.collaborators.length === 0 ? ( +

No hay colaboradores asociados.

+ ) : ( +
+ {insights.collaborators.map((collaborator) => ( +
+
+ {collaborator.avatar ? : collaboratorName(collaborator).charAt(0).toUpperCase()} +
+
+
{collaboratorName(collaborator)}
+
{collaborator.email || 'Sin email disponible'}
+
Roles: {collaborator.roles?.join(', ') || '-'}
+
+ + {collaborator.active ? 'Activo' : 'Removido'} + +
+ ))} +
+ )} +
+
+ )} +
+
+ ); +}; diff --git a/sysadmin-cli/src/index.css b/sysadmin-cli/src/index.css index 5dc3f40..08a17c8 100644 --- a/sysadmin-cli/src/index.css +++ b/sysadmin-cli/src/index.css @@ -160,3 +160,36 @@ button { .animate-fade-in { 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; + } +} diff --git a/sysadmin-cli/src/pages/CompaniesPage.tsx b/sysadmin-cli/src/pages/CompaniesPage.tsx index ad76f8e..55df151 100644 --- a/sysadmin-cli/src/pages/CompaniesPage.tsx +++ b/sysadmin-cli/src/pages/CompaniesPage.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; 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 { EditCompanyModal } from '../components/ui/EditCompanyModal'; import { CompanyServicesModal } from '../components/ui/CompanyServicesModal'; +import { OrganizationInsightsModal } from '../components/ui/OrganizationInsightsModal'; export const CompaniesPage = () => { const [companies, setCompanies] = useState([]); @@ -22,6 +23,7 @@ export const CompaniesPage = () => { // Modals state const [editingCompany, setEditingCompany] = useState(null); const [servicesCompany, setServicesCompany] = useState(null); + const [insightsCompany, setInsightsCompany] = useState(null); const loadCompanies = async () => { setLoading(true); @@ -176,6 +178,7 @@ export const CompaniesPage = () => {
+
@@ -197,68 +200,10 @@ export const CompaniesPage = () => { -