4 Commits

13 changed files with 864 additions and 136 deletions

View File

@@ -158,7 +158,7 @@ export const trainingSurveys = t.pgTable(
updatedBy: t
.integer('updated_by')
.references(() => users.id, { onDelete: 'cascade' }),
surveyStatus: t.text('survey_status').notNull().default('PUBLICADO'),
surveyStatus: t.text('survey_status').notNull().default('COMPLETADA'),
...timestamps,
},
(trainingSurveys) => ({

View File

@@ -2,7 +2,7 @@ import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import * as schema from '@/database/index';
import { states } from '@/database/schema/general';
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { and, eq, ne } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { CreateStateDto } from './dto/create-state.dto';
import { UpdateStateDto } from './dto/update-state.dto';
@@ -15,14 +15,17 @@ export class StatesService {
) {}
async findAll(): Promise<State[]> {
return await this.drizzle.select().from(states);
return await this.drizzle
.select()
.from(states)
.where(ne(states.name, 'EMBAJADA'));
}
async findOne(id: number): Promise<State> {
const state = await this.drizzle
.select()
.from(states)
.where(eq(states.id, id));
.where(and(eq(states.id, id), ne(states.name, 'EMBAJADA')));
if (state.length === 0) {
throw new HttpException('State not found', HttpStatus.NOT_FOUND);

View File

@@ -1,29 +1,30 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
// import { Env, validateString } from '@/common/utils';
import { Inject, Injectable, HttpException, HttpStatus, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { eq, ne } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index';
import { states, municipalities, parishes } from 'src/database/index';
import { eq, like, or, SQL, sql, and, not } from 'drizzle-orm';
import * as bcrypt from 'bcryptjs';
import { State, Municipality, Parish } from './entities/user.entity';
import { municipalities, parishes, states } from 'src/database/index';
import { Municipality, Parish, State } from './entities/user.entity';
// import { PaginationDto } from '../../common/dto/pagination.dto';
@Injectable()
export class UsersService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { }
) {}
async StateAll(): Promise< State[]> {
async StateAll(): Promise<State[]> {
const find = await this.drizzle
.select()
.from(states)
.where(ne(states.name, 'EMBAJADA'));
return find;
}
async MunicioalityAll(id: string): Promise< Municipality[]> {
async MunicioalityAll(id: string): Promise<Municipality[]> {
const find = await this.drizzle
.select()
.from(municipalities)
@@ -32,7 +33,7 @@ export class UsersService {
return find;
}
async ParishAll(id: string): Promise< Parish[]> {
async ParishAll(id: string): Promise<Parish[]> {
const find = await this.drizzle
.select()
.from(parishes)
@@ -41,4 +42,3 @@ export class UsersService {
return find;
}
}

View File

@@ -3,11 +3,13 @@ import {
Controller,
Delete,
Get,
Header,
Param,
Patch,
Post,
Query,
Req,
StreamableFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
@@ -24,6 +26,7 @@ import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingService } from './training.service';
import { Public } from '@/common/decorators';
@ApiTags('training')
@Controller('training')
@@ -76,6 +79,38 @@ export class TrainingController {
const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training statistics fetched successfully', data };
}
// ========== //
// @Get('export/all')
// @ApiOperation({ summary: 'Export all training records to Excel' })
// @ApiResponse({
// status: 200,
// description: 'Return training records Excel.',
// })
// async exportAll(@Query() filterDto: TrainingStatisticsFilterDto) {
// const data = await this.trainingService.exportAll(filterDto);
// return new StreamableFile(data, {
// type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// disposition: 'attachment; filename=training_surveys.xlsx',
// });
// }
@Public()
@Get('export/all')
@ApiOperation({ summary: 'Export all training records to Excel' })
@ApiResponse({
status: 200,
description: 'Return training template.',
content: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { schema: { type: 'string', format: 'binary' } } }
})
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename=export_osp.xlsx')
async exportAll(@Query() filterDto: TrainingStatisticsFilterDto) {
const data = await this.trainingService.exportAll(filterDto);
return new StreamableFile(data, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename=training_surveys.xlsx',
});
}
// ========== //
@Get(':id')

View File

@@ -4,7 +4,12 @@ import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import * as schema from 'src/database/index';
import { states, trainingSurveys } from 'src/database/index';
import { municipalities, parishes, states, trainingSurveys } from 'src/database/index';
import * as fs from 'fs';
import * as path from 'path';
// @ts-ignore
import XlsxPopulate from 'xlsx-populate';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto';
@@ -98,20 +103,30 @@ export class TrainingService {
if (municipalityId)
filters.push(eq(trainingSurveys.municipality, municipalityId));
if (parishId) filters.push(eq(trainingSurveys.parish, parishId));
if (ospType) filters.push(eq(trainingSurveys.ospType, ospType));
if (ospType && ospType !== 'all') filters.push(eq(trainingSurveys.ospType, ospType));
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
// Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad
const [
totalOspsResult,
totalProducersResult,
totalProductsResult, // Nuevo: Calculado desde el JSON
// totalProducersResult,
totalProductsResult,
statusDistribution,
activityDistribution,
typeDistribution,
stateDistribution,
yearDistribution,
ecoSectorDistribution,
productiveSectorDistribution,
centralActivityDistribution,
mainActivityDistribution,
structureTypeDistribution,
isOpenSpaceDistribution,
hasTransportDistribution,
genderResult,
municipalityDistribution,
parishDistribution,
] = await Promise.all([
// 1. Total OSPs
this.drizzle
@@ -120,12 +135,12 @@ export class TrainingService {
.where(whereCondition),
// 2. Total Productores (Columna plana que mantuviste)
this.drizzle
.select({
sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
})
.from(trainingSurveys)
.where(whereCondition),
// this.drizzle
// .select({
// sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
// })
// .from(trainingSurveys)
// .where(whereCondition),
// 3. NUEVO: Total Productos (Contamos el largo del array JSON productList)
this.drizzle
@@ -145,7 +160,7 @@ export class TrainingService {
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus),
// 5. Distribución por Actividad
// 5. Distribución por Actividad (General)
this.drizzle
.select({
name: trainingSurveys.productiveActivity,
@@ -188,11 +203,115 @@ export class TrainingService {
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear),
// 9. Distribución por Sector Económico
this.drizzle
.select({
name: trainingSurveys.ecoSector,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ecoSector),
// 10. Distribución por Sector Productivo
this.drizzle
.select({
name: trainingSurveys.productiveSector,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveSector),
// 11. Distribución por Actividad Central Productiva
this.drizzle
.select({
name: trainingSurveys.centralProductiveActivity,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.centralProductiveActivity),
// 12. Distribución por Actividad Productiva Principal
this.drizzle
.select({
name: trainingSurveys.mainProductiveActivity,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.mainProductiveActivity),
// 13. Distribución por Tipo de Estructura
this.drizzle
.select({
name: trainingSurveys.structureType,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.structureType),
// 14. Distribución por Espacio Abierto
this.drizzle
.select({
name: sql<string>`case when ${trainingSurveys.isOpenSpace} then 'Sí' else 'No' end`,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.isOpenSpace),
// 15. Distribución por Transporte
this.drizzle
.select({
name: sql<string>`case when ${trainingSurveys.hasTransport} then 'Sí' else 'No' end`,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.hasTransport),
// 16. Distribución por Género
this.drizzle
.select({
women: sql<number>`sum(${trainingSurveys.womenCount})`,
men: sql<number>`sum(${trainingSurveys.menCount})`,
})
.from(trainingSurveys)
.where(whereCondition),
// 17. Distribución por Municipio
this.drizzle
.select({
name: sql<string>`COALESCE(${municipalities.name}, 'Sin Asignar')`,
value: sql<number>`count(${trainingSurveys.id})`,
})
.from(trainingSurveys)
.leftJoin(
municipalities,
eq(trainingSurveys.municipality, municipalities.id),
)
.where(whereCondition)
.groupBy(municipalities.name),
// 18. Distribución por Parroquia
this.drizzle
.select({
name: sql<string>`COALESCE(${parishes.name}, 'Sin Asignar')`,
value: sql<number>`count(${trainingSurveys.id})`,
})
.from(trainingSurveys)
.leftJoin(parishes, eq(trainingSurveys.parish, parishes.id))
.where(whereCondition)
.groupBy(parishes.name),
]);
return {
totalOsps: Number(totalOspsResult[0]?.count || 0),
totalProducers: Number(totalProducersResult[0]?.sum || 0),
// totalProducers: Number(totalProducersResult[0]?.sum || 0),
totalProducts: Number(totalProductsResult[0]?.sum || 0), // Dato extraído del JSON
statusDistribution: statusDistribution.map((item) => ({
@@ -215,6 +334,46 @@ export class TrainingService {
...item,
value: Number(item.value),
})),
ecoSectorDistribution: ecoSectorDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
productiveSectorDistribution: productiveSectorDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
centralActivityDistribution: centralActivityDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
mainActivityDistribution: mainActivityDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
structureTypeDistribution: structureTypeDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
isOpenSpaceDistribution: isOpenSpaceDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
hasTransportDistribution: hasTransportDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
genderDistribution: [
{ name: 'Mujeres', value: Number(genderResult[0]?.women || 0) },
{ name: 'Hombres', value: Number(genderResult[0]?.men || 0) },
],
municipalityDistribution: municipalityDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
parishDistribution: parishDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
};
}
@@ -710,4 +869,178 @@ export class TrainingService {
// return book.outputAsync();
// }
async exportAll(filterDto: TrainingStatisticsFilterDto) {
try {
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 && ospType !== 'all')
filters.push(eq(trainingSurveys.ospType, ospType));
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
const records = await this.drizzle
.select({
coorFullName: trainingSurveys.coorFullName,
visitDate: trainingSurveys.visitDate,
stateName: states.name,
municipalityName: municipalities.name,
parishName: parishes.name,
communeName: trainingSurveys.communeName,
siturCodeCommune: trainingSurveys.siturCodeCommune,
communalCouncil: trainingSurveys.communalCouncil,
siturCodeCommunalCouncil: trainingSurveys.siturCodeCommunalCouncil,
productiveActivity: trainingSurveys.productiveActivity,
ospName: trainingSurveys.ospName,
ospAddress: trainingSurveys.ospAddress,
ospRif: trainingSurveys.ospRif,
ospType: trainingSurveys.ospType,
currentStatus: trainingSurveys.currentStatus,
companyConstitutionYear: trainingSurveys.companyConstitutionYear,
ospResponsibleFullname: trainingSurveys.ospResponsibleFullname,
ospResponsibleCedula: trainingSurveys.ospResponsibleCedula,
ospResponsibleRif: trainingSurveys.ospResponsibleRif,
ospResponsiblePhone: trainingSurveys.ospResponsiblePhone,
ospResponsibleEmail: trainingSurveys.ospResponsibleEmail,
civilState: trainingSurveys.civilState,
familyBurden: trainingSurveys.familyBurden,
numberOfChildren: trainingSurveys.numberOfChildren,
generalObservations: trainingSurveys.generalObservations,
paralysisReason: trainingSurveys.paralysisReason,
productList: trainingSurveys.productList,
infrastructureMt2: trainingSurveys.infrastructureMt2,
photo1: trainingSurveys.photo1,
photo2: trainingSurveys.photo2,
photo3: trainingSurveys.photo3,
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.leftJoin(
municipalities,
eq(trainingSurveys.municipality, municipalities.id),
)
.leftJoin(parishes, eq(trainingSurveys.parish, parishes.id))
.where(whereCondition)
.execute();
const workbook: any = await XlsxPopulate.fromBlankAsync();
const sheet = workbook.sheet(0);
const headers = [
'Coordinador',
'Fecha',
'Hora',
'Estado',
'Municipio',
'Parroquia',
'Comuna',
'Código SITUR Comuna',
'Consejo Comunal',
'Código SITUR C.C.',
'Actividad Productiva',
'Nombre OSP',
'Dirección OSP',
'RIF OSP',
'Tipo OSP',
'Estatus Actual',
'Año Constitución',
'Total Productores',
'Productos',
'Infraestructura (mt2)',
'Motivo Paralización',
'Responsable',
'Cédula',
'RIF Responsable',
'Teléfono',
'Email',
'Estado Civil',
'Carga Familiar',
'Nro Hijos',
'Observaciones',
// 'Foto 1',
// 'Foto 2',
// 'Foto 3',
];
// Configurar encabezados
sheet.range('A1:AG1').value([headers]).style({
bold: true,
fill: 'BFBFBF',
});
let currentRow = 2;
for (const record of records) {
const date = new Date(record.visitDate);
const dateStr = date.toLocaleDateString('es-VE');
const timeStr = date.toLocaleTimeString('es-VE');
const products = (record.productList as any[]) || [];
const totalProducers = products.reduce(
(sum, p) =>
sum + (Number(p.menCount) || 0) + (Number(p.womenCount) || 0),
0,
);
const productsDesc = products.map((p) => p.name).join(', ');
const rowData = [
record.coorFullName,
dateStr,
timeStr,
record.stateName || '',
record.municipalityName || '',
record.parishName || '',
record.communeName,
record.siturCodeCommune,
record.communalCouncil,
record.siturCodeCommunalCouncil,
record.productiveActivity,
record.ospName,
record.ospAddress,
record.ospRif,
record.ospType,
record.currentStatus,
record.companyConstitutionYear,
totalProducers,
productsDesc,
record.infrastructureMt2,
record.paralysisReason || '',
record.ospResponsibleFullname,
record.ospResponsibleCedula,
record.ospResponsibleRif,
record.ospResponsiblePhone,
record.ospResponsibleEmail,
record.civilState,
record.familyBurden,
record.numberOfChildren,
record.generalObservations || '',
// record.photo1 || '',
// record.photo2 || '',
// record.photo3 || '',
];
sheet.cell(`A${currentRow}`).value([rowData]);
currentRow++;
}
return await workbook.outputAsync();
} catch (error: any) {
console.error('Export Error:', error);
throw new HttpException(
'Error al generar el archivo Excel: ' + error.message,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@@ -9,11 +9,11 @@ export const metadata: Metadata = {
export default function SocioproductivaStatisticsPage() {
return (
<PageContainer>
<div className="w-full">
// <PageContainer>
<div className="w-full p-6">
<h1 className="text-2xl font-bold mb-6">Estadísticas Socioproductivas</h1>
<TrainingStatistics />
</div>
</PageContainer>
// </PageContainer>
);
}

View File

@@ -6,6 +6,7 @@ import {
TrainingSchema,
trainingApiResponseSchema,
} from '../schemas/training';
import z from 'zod';
export const getTrainingStatisticsAction = async (
params: {
@@ -159,3 +160,39 @@ export const getTrainingByIdAction = async (id: number) => {
return response?.data;
};
export const exportTrainingAction = 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(
z.any(), //Schema
`/training/export/all?${searchParams.toString()}`,
'GET',
undefined,
{ responseType: 'arraybuffer' },
);
if (error) {
throw new Error(error.message || 'Error al exportar los datos');
}
return Array.from(new Uint8Array(response));
};

View File

@@ -107,7 +107,8 @@ export function CreateTrainingForm({
coorPhone: defaultValues?.coorPhone || '',
visitDate: formatToLocalISO(defaultValues?.visitDate),
productiveActivity: defaultValues?.productiveActivity || undefined,
productiveActivityOther: defaultValues?.productiveActivityOther || undefined,
productiveActivityOther:
defaultValues?.productiveActivityOther || undefined,
ecoSector: defaultValues?.ecoSector || undefined,
productiveSector: defaultValues?.productiveSector || undefined,
centralProductiveActivity:
@@ -172,7 +173,7 @@ export function CreateTrainingForm({
womenCount: defaultValues?.womenCount || 0,
menCount: defaultValues?.menCount || 0,
surveyStatus: defaultValues?.surveyStatus || 'BORRADOR'
surveyStatus: defaultValues?.surveyStatus || 'BORRADOR',
},
mode: 'onChange',
});
@@ -240,7 +241,7 @@ export function CreateTrainingForm({
productiveSector,
centralProductiveActivity,
mainProductiveActivity,
productiveActivity
productiveActivity,
]);
const stateOptions = dataState?.data || [{ id: 0, name: 'Sin estados' }];
@@ -690,8 +691,8 @@ export function CreateTrainingForm({
)}
/>
{other && (<FormField
{other && (
<FormField
control={form.control}
name="productiveActivityOther"
render={({ field }) => (
@@ -700,16 +701,13 @@ export function CreateTrainingForm({
¿Cuál otra Actividad Productiva?
</FormLabel>
<FormControl>
<Input
{...field}
value={field.value}
/>
<Input {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>)}
/>
)}
<FormField
control={form.control}
@@ -756,11 +754,7 @@ export function CreateTrainingForm({
Año de constitución
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value}
/>
<Input type="number" {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1291,11 +1285,7 @@ export function CreateTrainingForm({
Correo Electrónico de la Comuna (Opcional)
</FormLabel>
<FormControl>
<Input
type="email"
{...field}
value={field.value}
/>
<Input type="email" {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1405,11 +1395,7 @@ export function CreateTrainingForm({
Correo Electrónico del Consejo Comunal (Opcional)
</FormLabel>
<FormControl>
<Input
type="email"
{...field}
value={field.value}
/>
<Input type="email" {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1555,9 +1541,7 @@ export function CreateTrainingForm({
</div>
<div className="flex flex-col gap-2">
<FormLabel>
Subir imágenes (Máximo 3 y opcional)
</FormLabel>
<FormLabel>Subir imágenes (Máximo 3 y opcional)</FormLabel>
<Input
type="file"
multiple
@@ -1643,13 +1627,8 @@ export function CreateTrainingForm({
</CardContent>
</Card>
<div className="grid grid-cols-2 md:grid-cols-3 justify-items-end gap-3 mt-8">
<Button
variant="outline"
type="button"
onClick={onCancel}
className="w-32 col-span-2 md:col-span-1"
>
<div className="flex justify-end gap-4">
<Button variant="outline" type="button" onClick={onCancel}>
Cancelar
</Button>
@@ -1668,8 +1647,10 @@ export function CreateTrainingForm({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem defaultChecked value="BORRADOR">BORRADOR</SelectItem>
<SelectItem value="PUBLICADO">PUBLICADO</SelectItem>
<SelectItem defaultChecked value="BORRADOR">
BORRADOR
</SelectItem>
<SelectItem value="COMPLETADA">COMPLETADA</SelectItem>
</SelectContent>
</Select>
<FormMessage />

View File

@@ -37,6 +37,8 @@ import {
YAxis,
} from 'recharts';
import { useTrainingStatsQuery } from '../hooks/use-training-statistics';
import { exportTrainingAction } from '../actions/training-actions';
import { Download } from 'lucide-react';
const OSP_TYPES = [
'EPSD',
@@ -89,6 +91,38 @@ export function TrainingStatistics() {
setOspType('');
};
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
try {
setIsExporting(true);
const bytes = await exportTrainingAction({
startDate: startDate || undefined,
endDate: endDate || undefined,
stateId: stateId || undefined,
municipalityId: municipalityId || undefined,
parishId: parishId || undefined,
ospType: ospType || undefined,
});
const blob = new Blob([new Uint8Array(bytes)], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `entrenamientos_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Error exporting:', error);
} finally {
setIsExporting(false);
}
};
if (isLoading) {
return (
<div className="flex justify-center p-8">Cargando estadísticas...</div>
@@ -103,12 +137,22 @@ export function TrainingStatistics() {
const {
totalOsps,
totalProducers,
// totalProducers,
statusDistribution,
activityDistribution,
typeDistribution,
stateDistribution,
yearDistribution,
ecoSectorDistribution,
productiveSectorDistribution,
centralActivityDistribution,
mainActivityDistribution,
structureTypeDistribution,
isOpenSpaceDistribution,
hasTransportDistribution,
genderDistribution,
municipalityDistribution,
parishDistribution,
} = data;
const COLORS = [
@@ -206,10 +250,19 @@ export function TrainingStatistics() {
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<div className="flex items-end gap-2">
<Button variant="outline" onClick={handleClearFilters}>
Limpiar Filtros
</Button>
<Button
variant="default"
onClick={handleExport}
disabled={isExporting}
className="bg-green-600 hover:bg-green-700"
>
<Download className="mr-2 h-4 w-4" />
{isExporting ? 'Exportando...' : 'Exportar Excel'}
</Button>
</div>
</div>
</CardContent>
@@ -230,7 +283,8 @@ export function TrainingStatistics() {
</p>
</CardContent>
</Card>
<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
@@ -242,7 +296,8 @@ export function TrainingStatistics() {
Productores asociados
</p>
</CardContent>
</Card>
</Card> */}
<Card className="col-span-full">
<CardHeader>
@@ -269,8 +324,8 @@ export function TrainingStatistics() {
</CardContent>
</Card>
{/* State Distribution */}
{/* <Card className="col-span-full">
{/* Location Distribution (Dynamic) */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Distribución por Estado</CardTitle>
<CardDescription>OSP registradas por estado</CardDescription>
@@ -290,7 +345,69 @@ export function TrainingStatistics() {
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card> */}
</Card>
{stateId > 0 ? (
<Card className="col-span-full">
<CardHeader>
<CardTitle>Distribución por Municipio</CardTitle>
<CardDescription>OSP registradas en este estado</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={municipalityDistribution}
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="#0088FE" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
) : (
<Card className="col-span-full">
<CardHeader>
<CardTitle>Distribución por Municipio</CardTitle>
<CardDescription>Seleccione un estado</CardDescription>
</CardHeader>
</Card>
)}
{municipalityId > 0 ? (
<Card className="col-span-full">
<CardHeader>
<CardTitle>Distribución por Parroquia</CardTitle>
<CardDescription>OSP registradas en este municipio</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={parishDistribution}
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-full">
<CardHeader>
<CardTitle>Distribución por Parroquia</CardTitle>
<CardDescription>Seleccione un municipio</CardDescription>
</CardHeader>
</Card>
)}
{/* Year Distribution */}
<Card className="col-span-full lg:col-span-1">
@@ -367,6 +484,223 @@ export function TrainingStatistics() {
</ResponsiveContainer>
</CardContent>
</Card>
{/* ECO SECTOR DISTRIBUTION */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Sector Económico</CardTitle>
<CardDescription>
Distribución por sector económico
</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={ecoSectorDistribution}
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="#0088FE" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* PRODUCTIVE SECTOR DISTRIBUTION */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Sector Productivo</CardTitle>
<CardDescription>
Distribución por sector productivo
</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={productiveSectorDistribution}
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="#00C49F" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* CENTRAL PRODUCTIVE ACTIVITY DISTRIBUTION */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Actividad Central Productiva</CardTitle>
<CardDescription>
Distribución por actividad central productiva
</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={centralActivityDistribution}
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="#FF8042" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* MAIN PRODUCTIVE ACTIVITY DISTRIBUTION */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Actividad Productiva Principal</CardTitle>
<CardDescription>
Distribución por actividad productiva principal
</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={mainActivityDistribution}
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>
{/* STRUCTURE TYPE DISTRIBUTION */}
<Card className="col-span-full lg:col-span-2">
<CardHeader>
<CardTitle>Tipo de Estructura</CardTitle>
<CardDescription>Distribución física</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={structureTypeDistribution}
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>
{/* GENDER DISTRIBUTION */}
<Card className="col-span-full lg:col-span-1">
<CardHeader>
<CardTitle>Distribución de Género</CardTitle>
<CardDescription>Conteo total por género</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={genderDistribution}
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="#8884d8" name="Personas" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* OPEN SPACE AND TRANSPORT (PIE CHARTS) */}
<div className="col-span-full grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Espacio Abierto</CardTitle>
<CardDescription>¿Poseen áreas libres?</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={isOpenSpaceDistribution}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{isOpenSpaceDistribution.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[(index + 2) % COLORS.length]}
/>
))}
</Pie>
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Transporte</CardTitle>
<CardDescription>¿Tienen vehículo propio?</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={hasTransportDistribution}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{hasTransportDistribution.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[(index + 4) % COLORS.length]}
/>
))}
</Pie>
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</div>
</div>
);

View File

@@ -43,22 +43,13 @@ export function columns({ apiUrl }: ColumnsProps): ColumnDef<TrainingSchema>[] {
return date ? new Date(date).toLocaleString() : 'N/A';
},
},
{
accessorKey: 'visitDate',
header: 'Fecha Visita',
cell: ({ row }) => {
const date = row.getValue('visitDate') as string;
return date ? new Date(date).toLocaleString() : 'N/A';
},
},
{
accessorKey: 'surveyStatus',
header: '',
cell: ({ row }) => {
const status = row.getValue('surveyStatus') as string;
return (
<Badge variant={status === 'PUBLICADO' ? 'default' : 'secondary'}>
<Badge variant={status === 'COMPLETADA' ? 'success' : 'secondary'}>
{status}
</Badge>
);

View File

@@ -10,13 +10,23 @@ export const statisticsItemSchema = z.object({
export const trainingStatisticsSchema = z.object({
totalOsps: z.number(),
totalProducers: z.number(),
// totalProducers: z.number(),
totalProducts: z.number(),
statusDistribution: z.array(statisticsItemSchema),
activityDistribution: z.array(statisticsItemSchema),
typeDistribution: z.array(statisticsItemSchema),
stateDistribution: z.array(statisticsItemSchema),
yearDistribution: z.array(statisticsItemSchema),
ecoSectorDistribution: z.array(statisticsItemSchema),
productiveSectorDistribution: z.array(statisticsItemSchema),
centralActivityDistribution: z.array(statisticsItemSchema),
mainActivityDistribution: z.array(statisticsItemSchema),
structureTypeDistribution: z.array(statisticsItemSchema),
isOpenSpaceDistribution: z.array(statisticsItemSchema),
hasTransportDistribution: z.array(statisticsItemSchema),
genderDistribution: z.array(statisticsItemSchema),
municipalityDistribution: z.array(statisticsItemSchema),
parishDistribution: z.array(statisticsItemSchema),
});
export type TrainingStatisticsData = z.infer<typeof trainingStatisticsSchema>;

View File

@@ -1,6 +1,6 @@
'use server';
import { env } from '@/lib/env';
import axios, { InternalAxiosRequestConfig } from 'axios';
import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { z } from 'zod';
// Crear instancia de Axios con la URL base validada
@@ -32,6 +32,7 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
url: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
data?: any,
config?: AxiosRequestConfig,
): Promise<
[{ type: string; message: string; details?: any } | null, z.infer<T> | null]
> => {
@@ -40,6 +41,7 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
method,
url,
data,
...config,
});
const parsed = schema.safeParse(response.data);

View File

@@ -1,38 +1,40 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from "@repo/shadcn/lib/utils"
import { cn } from '@repo/shadcn/lib/utils';
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70",
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70',
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
success:
'border-transparent bg-green-500 text-black [a&]:hover:bg-green-300',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : 'span';
return (
<Comp
@@ -40,7 +42,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };