form guarda y estadisticas
This commit is contained in:
@@ -12,7 +12,7 @@ export class CreateTrainingDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
visitDate: Date;
|
visitDate: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { UpdateTrainingDto } from './dto/update-training.dto';
|
|||||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
|
||||||
|
|
||||||
@ApiTags('training')
|
@ApiTags('training')
|
||||||
@Controller('training')
|
@Controller('training')
|
||||||
export class TrainingController {
|
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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get a training record by ID' })
|
@ApiOperation({ summary: 'Get a training record by ID' })
|
||||||
@ApiResponse({ status: 200, description: 'Return the training record.' })
|
@ApiResponse({ status: 200, description: 'Return the training record.' })
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
|||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import * as schema from 'src/database/index';
|
import * as schema from 'src/database/index';
|
||||||
import { trainingSurveys } 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 { CreateTrainingDto } from './dto/create-training.dto';
|
||||||
import { UpdateTrainingDto } from './dto/update-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';
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -14,6 +16,7 @@ export class TrainingService {
|
|||||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|
||||||
async findAll(paginationDto?: PaginationDto) {
|
async findAll(paginationDto?: PaginationDto) {
|
||||||
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
|
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
|
||||||
|
|
||||||
@@ -63,6 +66,108 @@ export class TrainingService {
|
|||||||
return { data, meta };
|
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<number>`count(*)` })
|
||||||
|
.from(trainingSurveys)
|
||||||
|
.where(whereCondition);
|
||||||
|
const totalOsps = Number(totalOspsResult[0].count);
|
||||||
|
|
||||||
|
const totalProducersResult = await this.drizzle
|
||||||
|
.select({ sum: sql<number>`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<number>`count(*)`
|
||||||
|
})
|
||||||
|
.from(trainingSurveys)
|
||||||
|
.where(whereCondition)
|
||||||
|
.groupBy(trainingSurveys.currentStatus);
|
||||||
|
|
||||||
|
const activityDistribution = await this.drizzle
|
||||||
|
.select({
|
||||||
|
name: trainingSurveys.productiveActivity,
|
||||||
|
value: sql<number>`count(*)`
|
||||||
|
})
|
||||||
|
.from(trainingSurveys)
|
||||||
|
.where(whereCondition)
|
||||||
|
.groupBy(trainingSurveys.productiveActivity);
|
||||||
|
|
||||||
|
const typeDistribution = await this.drizzle
|
||||||
|
.select({
|
||||||
|
name: trainingSurveys.ospType,
|
||||||
|
value: sql<number>`count(*)`
|
||||||
|
})
|
||||||
|
.from(trainingSurveys)
|
||||||
|
.where(whereCondition)
|
||||||
|
.groupBy(trainingSurveys.ospType);
|
||||||
|
|
||||||
|
// New Aggregations
|
||||||
|
const stateDistribution = await this.drizzle
|
||||||
|
.select({
|
||||||
|
name: states.name,
|
||||||
|
value: sql<number>`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<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
|
||||||
|
value: sql<number>`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) {
|
async findOne(id: number) {
|
||||||
const find = await this.drizzle
|
const find = await this.drizzle
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
19
apps/web/app/dashboard/estadisticas/socioproductiva/page.tsx
Normal file
19
apps/web/app/dashboard/estadisticas/socioproductiva/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Estadísticas Socioproductivas</h1>
|
||||||
|
<TrainingStatistics />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,6 +79,13 @@ export const StatisticsItems: NavItem[] = [
|
|||||||
icon: 'notepadText',
|
icon: 'notepadText',
|
||||||
role: ['admin', 'superadmin', 'autoridad'],
|
role: ['admin', 'superadmin', 'autoridad'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Socioproductiva',
|
||||||
|
shortcut: ['s', 's'],
|
||||||
|
url: '/dashboard/estadisticas/socioproductiva',
|
||||||
|
icon: 'blocks',
|
||||||
|
role: ['admin', 'superadmin', 'autoridad'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,35 @@ import {
|
|||||||
TrainingMutate,
|
TrainingMutate,
|
||||||
trainingApiResponseSchema
|
trainingApiResponseSchema
|
||||||
} from '../schemas/training';
|
} 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: {
|
export const getTrainingAction = async (params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -161,7 +161,21 @@ export function CreateTrainingForm({
|
|||||||
<FormField control={form.control} name="visitDate" render={({ field }) => (
|
<FormField control={form.control} name="visitDate" render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Fecha de la visita</FormLabel>
|
<FormLabel>Fecha de la visita</FormLabel>
|
||||||
<FormControl><Input type="date" {...field} value={field.value ? new Date(field.value).toISOString().split('T')[0] : ''} /></FormControl>
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={field.value ? new Date(field.value).toISOString().split('T')[0] : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Convert YYYY-MM-DD to ISO 8601 string
|
||||||
|
const dateValue = e.target.value;
|
||||||
|
if (dateValue) {
|
||||||
|
field.onChange(new Date(dateValue).toISOString());
|
||||||
|
} else {
|
||||||
|
field.onChange('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)} />
|
)} />
|
||||||
|
|||||||
319
apps/web/feactures/training/components/training-statistics.tsx
Normal file
319
apps/web/feactures/training/components/training-statistics.tsx
Normal file
@@ -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<string>('');
|
||||||
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
|
const [stateId, setStateId] = useState<number>(0);
|
||||||
|
const [municipalityId, setMunicipalityId] = useState<number>(0);
|
||||||
|
const [parishId, setParishId] = useState<number>(0);
|
||||||
|
const [ospType, setOspType] = useState<string>('');
|
||||||
|
|
||||||
|
// 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 <div className="flex justify-center p-8">Cargando estadísticas...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <div className="flex justify-center p-8">No hay datos disponibles.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalOsps, totalProducers, statusDistribution, activityDistribution, typeDistribution, stateDistribution, yearDistribution } = data;
|
||||||
|
|
||||||
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Filtros</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Fecha Inicio</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Fecha Fin</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Estado</label>
|
||||||
|
<SelectSearchable
|
||||||
|
options={stateOptions.map((item) => ({
|
||||||
|
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() : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Municipio</label>
|
||||||
|
<SelectSearchable
|
||||||
|
options={municipalityOptions.map((item) => ({
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Parroquia</label>
|
||||||
|
<SelectSearchable
|
||||||
|
options={parishOptions.map((item) => ({
|
||||||
|
value: item.id.toString(),
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
onValueChange={(value: any) => setParishId(Number(value))}
|
||||||
|
placeholder="Selecciona parroquia"
|
||||||
|
defaultValue={parishId ? parishId.toString() : ""}
|
||||||
|
disabled={!municipalityId || municipalityId === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Tipo de OSP</label>
|
||||||
|
<Select value={ospType} onValueChange={setOspType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{OSP_TYPES.map(type => (
|
||||||
|
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button variant="outline" onClick={handleClearFilters}>
|
||||||
|
Limpiar Filtros
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total de OSP Registradas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalOsps}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Organizaciones Socioproductivas
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total de Productores</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{totalProducers}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Productores asociados
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="col-span-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Actividad Productiva</CardTitle>
|
||||||
|
<CardDescription>Distribución por tipo de actividad</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[400px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={activityDistribution}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" />
|
||||||
|
<YAxis dataKey="name" type="category" width={150} />
|
||||||
|
<Tooltip wrapperStyle={{ color: '#000' }} />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="value" fill="#8884d8" name="Cantidad" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* State Distribution */}
|
||||||
|
<Card className="col-span-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Distribución por Estado</CardTitle>
|
||||||
|
<CardDescription>OSP registradas por estado</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[400px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={stateDistribution}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip wrapperStyle={{ color: '#000' }} />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="value" fill="#00C49F" name="Cantidad" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Year Distribution */}
|
||||||
|
<Card className="col-span-full lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Año de Constitución</CardTitle>
|
||||||
|
<CardDescription>Año de registro de la empresa</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[400px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={yearDistribution}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip wrapperStyle={{ color: '#000' }} />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="value" fill="#FFBB28" name="Cantidad" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="col-span-1 md:col-span-2 lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Estatus Actual</CardTitle>
|
||||||
|
<CardDescription>Estado operativo de las OSP</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusDistribution}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusDistribution.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip wrapperStyle={{ color: '#000' }} />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-1 md:col-span-2 lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tipo de Organización</CardTitle>
|
||||||
|
<CardDescription>Clasificación de las OSP</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={typeDistribution}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip wrapperStyle={{ color: '#000' }} />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="value" fill="#82ca9d" name="Cantidad" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/web/feactures/training/hooks/use-training-statistics.ts
Normal file
13
apps/web/feactures/training/hooks/use-training-statistics.ts
Normal file
@@ -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));
|
||||||
|
}
|
||||||
23
apps/web/feactures/training/schemas/statistics.ts
Normal file
23
apps/web/feactures/training/schemas/statistics.ts
Normal file
@@ -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<typeof trainingStatisticsSchema>;
|
||||||
|
|
||||||
|
export const trainingStatisticsResponseSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
data: trainingStatisticsSchema,
|
||||||
|
});
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export type SurveyTable = z.infer<typeof user>;
|
|
||||||
export type CreateUser = z.infer<typeof createUser>;
|
|
||||||
export type UpdateUser = z.infer<typeof updateUser>;
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user