From 24bc0476e6e944fe5f9350c31fac16f21911a410 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 9 Dec 2025 17:56:48 -0400 Subject: [PATCH] form guarda y estadisticas --- .../training/dto/create-training.dto.ts | 2 +- .../dto/training-statistics-filter.dto.ts | 38 +++ .../features/training/training.controller.ts | 10 + .../src/features/training/training.service.ts | 107 +++++- .../estadisticas/socioproductiva/page.tsx | 19 ++ apps/web/constants/routes.ts | 7 + .../training/actions/training-actions.ts | 29 ++ .../feactures/training/components/form.tsx | 16 +- .../components/training-statistics.tsx | 319 ++++++++++++++++++ .../training/hooks/use-training-statistics.ts | 13 + .../feactures/training/schemas/statistics.ts | 23 ++ apps/web/feactures/training/schemas/users.ts | 67 ---- 12 files changed, 580 insertions(+), 70 deletions(-) create mode 100644 apps/api/src/features/training/dto/training-statistics-filter.dto.ts create mode 100644 apps/web/app/dashboard/estadisticas/socioproductiva/page.tsx create mode 100644 apps/web/feactures/training/components/training-statistics.tsx create mode 100644 apps/web/feactures/training/hooks/use-training-statistics.ts create mode 100644 apps/web/feactures/training/schemas/statistics.ts delete mode 100644 apps/web/feactures/training/schemas/users.ts diff --git a/apps/api/src/features/training/dto/create-training.dto.ts b/apps/api/src/features/training/dto/create-training.dto.ts index d6e255d..901f913 100644 --- a/apps/api/src/features/training/dto/create-training.dto.ts +++ b/apps/api/src/features/training/dto/create-training.dto.ts @@ -12,7 +12,7 @@ export class CreateTrainingDto { @ApiProperty() @IsDateString() - visitDate: Date; + visitDate: string; @ApiProperty() @IsString() diff --git a/apps/api/src/features/training/dto/training-statistics-filter.dto.ts b/apps/api/src/features/training/dto/training-statistics-filter.dto.ts new file mode 100644 index 0000000..d2496f3 --- /dev/null +++ b/apps/api/src/features/training/dto/training-statistics-filter.dto.ts @@ -0,0 +1,38 @@ +import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class TrainingStatisticsFilterDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + @IsNumber() + stateId?: number; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + @IsNumber() + municipalityId?: number; + + @ApiPropertyOptional() + @IsOptional() + @Type(() => Number) + @IsNumber() + parishId?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + ospType?: string; +} diff --git a/apps/api/src/features/training/training.controller.ts b/apps/api/src/features/training/training.controller.ts index 8f88032..1dbe3b3 100644 --- a/apps/api/src/features/training/training.controller.ts +++ b/apps/api/src/features/training/training.controller.ts @@ -5,6 +5,8 @@ import { UpdateTrainingDto } from './dto/update-training.dto'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { PaginationDto } from '../../common/dto/pagination.dto'; +import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; + @ApiTags('training') @Controller('training') export class TrainingController { @@ -22,6 +24,14 @@ export class TrainingController { }; } + @Get('statistics') + @ApiOperation({ summary: 'Get training statistics' }) + @ApiResponse({ status: 200, description: 'Return training statistics.' }) + async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) { + const data = await this.trainingService.getStatistics(filterDto); + return { message: 'Training statistics fetched successfully', data }; + } + @Get(':id') @ApiOperation({ summary: 'Get a training record by ID' }) @ApiResponse({ status: 200, description: 'Return the training record.' }) diff --git a/apps/api/src/features/training/training.service.ts b/apps/api/src/features/training/training.service.ts index 5052551..0123375 100644 --- a/apps/api/src/features/training/training.service.ts +++ b/apps/api/src/features/training/training.service.ts @@ -3,9 +3,11 @@ import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import * as schema from 'src/database/index'; import { trainingSurveys } from 'src/database/index'; -import { eq, like, or, SQL, sql } from 'drizzle-orm'; +import { eq, like, or, and, gte, lte, SQL, sql } from 'drizzle-orm'; import { CreateTrainingDto } from './dto/create-training.dto'; import { UpdateTrainingDto } from './dto/update-training.dto'; +import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; +import { states } from 'src/database/index'; import { PaginationDto } from '../../common/dto/pagination.dto'; @Injectable() @@ -14,6 +16,7 @@ export class TrainingService { @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase, ) { } + async findAll(paginationDto?: PaginationDto) { const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {}; @@ -63,6 +66,108 @@ export class TrainingService { return { data, meta }; } + async getStatistics(filterDto: TrainingStatisticsFilterDto) { + const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto; + + const filters: SQL[] = []; + + if (startDate) { + filters.push(gte(trainingSurveys.visitDate, new Date(startDate))); + } + + if (endDate) { + filters.push(lte(trainingSurveys.visitDate, new Date(endDate))); + } + + if (stateId) { + filters.push(eq(trainingSurveys.state, stateId)); + } + + if (municipalityId) { + filters.push(eq(trainingSurveys.municipality, municipalityId)); + } + + if (parishId) { + filters.push(eq(trainingSurveys.parish, parishId)); + } + + if (ospType) { + filters.push(eq(trainingSurveys.ospType, ospType)); + } + + const whereCondition = filters.length > 0 ? and(...filters) : undefined; + + const totalOspsResult = await this.drizzle + .select({ count: sql`count(*)` }) + .from(trainingSurveys) + .where(whereCondition); + const totalOsps = Number(totalOspsResult[0].count); + + const totalProducersResult = await this.drizzle + .select({ sum: sql`sum(${trainingSurveys.producerCount})` }) + .from(trainingSurveys) + .where(whereCondition); + const totalProducers = Number(totalProducersResult[0].sum || 0); + + const statusDistribution = await this.drizzle + .select({ + name: trainingSurveys.currentStatus, + value: sql`count(*)` + }) + .from(trainingSurveys) + .where(whereCondition) + .groupBy(trainingSurveys.currentStatus); + + const activityDistribution = await this.drizzle + .select({ + name: trainingSurveys.productiveActivity, + value: sql`count(*)` + }) + .from(trainingSurveys) + .where(whereCondition) + .groupBy(trainingSurveys.productiveActivity); + + const typeDistribution = await this.drizzle + .select({ + name: trainingSurveys.ospType, + value: sql`count(*)` + }) + .from(trainingSurveys) + .where(whereCondition) + .groupBy(trainingSurveys.ospType); + + // New Aggregations + const stateDistribution = await this.drizzle + .select({ + name: states.name, + value: sql`count(${trainingSurveys.id})` + }) + .from(trainingSurveys) + .leftJoin(states, eq(trainingSurveys.state, states.id)) + .where(whereCondition) + .groupBy(states.name); + + const yearDistribution = await this.drizzle + .select({ + name: sql`cast(${trainingSurveys.companyConstitutionYear} as text)`, + value: sql`count(*)` + }) + .from(trainingSurveys) + .where(whereCondition) + .groupBy(trainingSurveys.companyConstitutionYear) + .orderBy(trainingSurveys.companyConstitutionYear); + + return { + totalOsps, + totalProducers, + statusDistribution: statusDistribution.map(item => ({ ...item, value: Number(item.value) })), + activityDistribution: activityDistribution.map(item => ({ ...item, value: Number(item.value) })), + typeDistribution: typeDistribution.map(item => ({ ...item, value: Number(item.value) })), + stateDistribution: stateDistribution.map(item => ({ ...item, value: Number(item.value) })), + yearDistribution: yearDistribution.map(item => ({ ...item, value: Number(item.value) })), + }; + } + async findOne(id: number) { const find = await this.drizzle .select() diff --git a/apps/web/app/dashboard/estadisticas/socioproductiva/page.tsx b/apps/web/app/dashboard/estadisticas/socioproductiva/page.tsx new file mode 100644 index 0000000..4cbc28c --- /dev/null +++ b/apps/web/app/dashboard/estadisticas/socioproductiva/page.tsx @@ -0,0 +1,19 @@ +import PageContainer from '@/components/layout/page-container'; +import { TrainingStatistics } from '@/feactures/training/components/training-statistics'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Estadísticas Socioproductivas - Fondemi', + description: 'Análisis y estadísticas de las Organizaciones Socioproductivas', +}; + +export default function SocioproductivaStatisticsPage() { + return ( + +
+

Estadísticas Socioproductivas

+ +
+
+ ); +} diff --git a/apps/web/constants/routes.ts b/apps/web/constants/routes.ts index f466f98..6b537e1 100644 --- a/apps/web/constants/routes.ts +++ b/apps/web/constants/routes.ts @@ -79,6 +79,13 @@ export const StatisticsItems: NavItem[] = [ icon: 'notepadText', role: ['admin', 'superadmin', 'autoridad'], }, + { + title: 'Socioproductiva', + shortcut: ['s', 's'], + url: '/dashboard/estadisticas/socioproductiva', + icon: 'blocks', + role: ['admin', 'superadmin', 'autoridad'], + }, ], }, ]; diff --git a/apps/web/feactures/training/actions/training-actions.ts b/apps/web/feactures/training/actions/training-actions.ts index ce410f1..de8efb8 100644 --- a/apps/web/feactures/training/actions/training-actions.ts +++ b/apps/web/feactures/training/actions/training-actions.ts @@ -5,6 +5,35 @@ import { TrainingMutate, trainingApiResponseSchema } from '../schemas/training'; +import { trainingStatisticsResponseSchema } from '../schemas/statistics'; + +export const getTrainingStatisticsAction = async (params: { + startDate?: string; + endDate?: string; + stateId?: number; + municipalityId?: number; + parishId?: number; + ospType?: string; +} = {}) => { + const searchParams = new URLSearchParams(); + if (params.startDate) searchParams.append('startDate', params.startDate); + if (params.endDate) searchParams.append('endDate', params.endDate); + if (params.stateId) searchParams.append('stateId', params.stateId.toString()); + if (params.municipalityId) searchParams.append('municipalityId', params.municipalityId.toString()); + if (params.parishId) searchParams.append('parishId', params.parishId.toString()); + if (params.ospType) searchParams.append('ospType', params.ospType); + + const [error, response] = await safeFetchApi( + trainingStatisticsResponseSchema, + `/training/statistics?${searchParams.toString()}`, + 'GET', + ); + + if (error) throw new Error(error.message); + + return response?.data; +} + export const getTrainingAction = async (params: { page?: number; diff --git a/apps/web/feactures/training/components/form.tsx b/apps/web/feactures/training/components/form.tsx index b02f874..04ddc8e 100644 --- a/apps/web/feactures/training/components/form.tsx +++ b/apps/web/feactures/training/components/form.tsx @@ -161,7 +161,21 @@ export function CreateTrainingForm({ ( Fecha de la visita - + + { + // Convert YYYY-MM-DD to ISO 8601 string + const dateValue = e.target.value; + if (dateValue) { + field.onChange(new Date(dateValue).toISOString()); + } else { + field.onChange(''); + } + }} + /> + )} /> diff --git a/apps/web/feactures/training/components/training-statistics.tsx b/apps/web/feactures/training/components/training-statistics.tsx new file mode 100644 index 0000000..a2a7c35 --- /dev/null +++ b/apps/web/feactures/training/components/training-statistics.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; +import { useTrainingStatsQuery } from '../hooks/use-training-statistics'; +import { Input } from '@repo/shadcn/input'; +import { Button } from '@repo/shadcn/button'; +import { SelectSearchable } from '@repo/shadcn/select-searchable'; +import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@repo/shadcn/select'; + +const OSP_TYPES = [ + 'EPSD', + 'EPSI', + 'UPF', + 'Cooperativa', + 'Grupo de Intercambio', +]; + +export function TrainingStatistics() { + // Filter State + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [stateId, setStateId] = useState(0); + const [municipalityId, setMunicipalityId] = useState(0); + const [parishId, setParishId] = useState(0); + const [ospType, setOspType] = useState(''); + + // Location Data + const { data: dataState } = useStateQuery(); + const { data: dataMunicipality } = useMunicipalityQuery(stateId); + const { data: dataParish } = useParishQuery(municipalityId); + + const stateOptions = dataState?.data || [{ id: 0, name: 'Sin estados' }]; + const municipalityOptions = Array.isArray(dataMunicipality?.data) && dataMunicipality.data.length > 0 + ? dataMunicipality.data + : [{ id: 0, stateId: 0, name: 'Sin Municipios' }]; + const parishOptions = Array.isArray(dataParish?.data) && dataParish.data.length > 0 + ? dataParish.data + : [{ id: 0, stateId: 0, name: 'Sin Parroquias' }]; + + // Query with Filters + const { data, isLoading, refetch } = useTrainingStatsQuery({ + startDate: startDate || undefined, + endDate: endDate || undefined, + stateId: stateId || undefined, + municipalityId: municipalityId || undefined, + parishId: parishId || undefined, + ospType: ospType || undefined, + }); + + const handleClearFilters = () => { + setStartDate(''); + setEndDate(''); + setStateId(0); + setMunicipalityId(0); + setParishId(0); + setOspType(''); + }; + + if (isLoading) { + return
Cargando estadísticas...
; + } + + if (!data) { + return
No hay datos disponibles.
; + } + + const { totalOsps, totalProducers, statusDistribution, activityDistribution, typeDistribution, stateDistribution, yearDistribution } = data; + + const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']; + + return ( +
+ {/* Filters Section */} + + + Filtros + + +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + ({ + value: item.id.toString(), + label: item.name, + }))} + onValueChange={(value: any) => { + setStateId(Number(value)); + setMunicipalityId(0); // Reset municipality + setParishId(0); // Reset parish + }} + placeholder="Selecciona un estado" + defaultValue={stateId ? stateId.toString() : ""} + /> +
+
+ + ({ + value: item.id.toString(), + label: item.name, + }))} + onValueChange={(value: any) => { + setMunicipalityId(Number(value)); + setParishId(0); + }} + placeholder="Selecciona municipio" + defaultValue={municipalityId ? municipalityId.toString() : ""} + disabled={!stateId || stateId === 0} + /> +
+
+ + ({ + value: item.id.toString(), + label: item.name, + }))} + onValueChange={(value: any) => setParishId(Number(value))} + placeholder="Selecciona parroquia" + defaultValue={parishId ? parishId.toString() : ""} + disabled={!municipalityId || municipalityId === 0} + /> +
+
+ + +
+
+ +
+
+
+
+ + {/* Statistics Cards */} +
+ + + Total de OSP Registradas + + +
{totalOsps}
+

+ Organizaciones Socioproductivas +

+
+
+ + + Total de Productores + + +
{totalProducers}
+

+ Productores asociados +

+
+
+ + + + Actividad Productiva + Distribución por tipo de actividad + + + + + + + + + + + + + + + + {/* State Distribution */} + + + Distribución por Estado + OSP registradas por estado + + + + + + + + + + + + + + + + {/* Year Distribution */} + + + Año de Constitución + Año de registro de la empresa + + + + + + + + + + + + + + + + + + Estatus Actual + Estado operativo de las OSP + + + + + + {statusDistribution.map((entry, index) => ( + + ))} + + + + + + + + + + Tipo de Organización + Clasificación de las OSP + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/web/feactures/training/hooks/use-training-statistics.ts b/apps/web/feactures/training/hooks/use-training-statistics.ts new file mode 100644 index 0000000..b043dd5 --- /dev/null +++ b/apps/web/feactures/training/hooks/use-training-statistics.ts @@ -0,0 +1,13 @@ +import { useSafeQuery } from '@/hooks/use-safe-query'; +import { getTrainingStatisticsAction } from '../actions/training-actions'; + +export function useTrainingStatsQuery(params: { + startDate?: string; + endDate?: string; + stateId?: number; + municipalityId?: number; + parishId?: number; + ospType?: string; +} = {}) { + return useSafeQuery(['training-statistics', JSON.stringify(params)], () => getTrainingStatisticsAction(params)); +} diff --git a/apps/web/feactures/training/schemas/statistics.ts b/apps/web/feactures/training/schemas/statistics.ts new file mode 100644 index 0000000..9a49655 --- /dev/null +++ b/apps/web/feactures/training/schemas/statistics.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const statisticsItemSchema = z.object({ + name: z.string(), + value: z.number(), +}); + +export const trainingStatisticsSchema = z.object({ + totalOsps: z.number(), + totalProducers: z.number(), + statusDistribution: z.array(statisticsItemSchema), + activityDistribution: z.array(statisticsItemSchema), + typeDistribution: z.array(statisticsItemSchema), + stateDistribution: z.array(statisticsItemSchema), + yearDistribution: z.array(statisticsItemSchema), +}); + +export type TrainingStatisticsData = z.infer; + +export const trainingStatisticsResponseSchema = z.object({ + message: z.string(), + data: trainingStatisticsSchema, +}); diff --git a/apps/web/feactures/training/schemas/users.ts b/apps/web/feactures/training/schemas/users.ts deleted file mode 100644 index 1e8c5d5..0000000 --- a/apps/web/feactures/training/schemas/users.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { z } from 'zod'; - -export type SurveyTable = z.infer; -export type CreateUser = z.infer; -export type UpdateUser = z.infer; - -export const user = z.object({ - id: z.number().optional(), - username: z.string(), - email: z.string(), - fullname: z.string(), - phone: z.string().nullable(), - isActive: z.boolean(), - role: z.string(), - state: z.string().optional().nullable(), - municipality: z.string().optional().nullable(), - parish: z.string().optional().nullable(), -}); - -export const createUser = z.object({ - id: z.number().optional(), - username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }), - password: z.string().min(8, { message: "Debe de tener 8 o más caracteres" }), - email: z.string().email({ message: "Correo no válido" }), - fullname: z.string(), - phone: z.string(), - confirmPassword: z.string(), - role: z.number() -}) -.refine((data) => data.password === data.confirmPassword, { - message: 'La contraseña no coincide', - path: ['confirmPassword'], -}) - -export const updateUser = z.object({ - id: z.number(), - username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }).or(z.literal('')), - password: z.string().min(6, { message: "Debe de tener 6 o más caracteres" }).or(z.literal('')), - email: z.string().email({ message: "Correo no válido" }).or(z.literal('')), - fullname: z.string().optional(), - phone: z.string().optional(), - role: z.number().optional(), - isActive: z.boolean().optional(), - state: z.number().optional().nullable(), - municipality: z.number().optional().nullable(), - parish: z.number().optional().nullable(), -}) - -export const surveysApiResponseSchema = z.object({ - message: z.string(), - data: z.array(user), - meta: z.object({ - page: z.number(), - limit: z.number(), - totalCount: z.number(), - totalPages: z.number(), - hasNextPage: z.boolean(), - hasPreviousPage: z.boolean(), - nextPage: z.number().nullable(), - previousPage: z.number().nullable(), - }), -}) - -export const UsersMutate = z.object({ - message: z.string(), - data: user, -}) \ No newline at end of file