diff --git a/apps/api/src/features/training/training.controller.ts b/apps/api/src/features/training/training.controller.ts index 41c2c28..8d10435 100644 --- a/apps/api/src/features/training/training.controller.ts +++ b/apps/api/src/features/training/training.controller.ts @@ -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') diff --git a/apps/api/src/features/training/training.service.ts b/apps/api/src/features/training/training.service.ts index 748a7b0..9b24f73 100644 --- a/apps/api/src/features/training/training.service.ts +++ b/apps/api/src/features/training/training.service.ts @@ -5,6 +5,11 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; import * as schema 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,7 +103,7 @@ 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; @@ -864,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, + ); + } + } } + diff --git a/apps/web/feactures/training/actions/training-actions.ts b/apps/web/feactures/training/actions/training-actions.ts index e73806a..8da71ed 100644 --- a/apps/web/feactures/training/actions/training-actions.ts +++ b/apps/web/feactures/training/actions/training-actions.ts @@ -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)); +}; diff --git a/apps/web/feactures/training/components/training-statistics.tsx b/apps/web/feactures/training/components/training-statistics.tsx index e1563d0..4bdca66 100644 --- a/apps/web/feactures/training/components/training-statistics.tsx +++ b/apps/web/feactures/training/components/training-statistics.tsx @@ -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 (
Cargando estadísticas...
@@ -216,10 +250,19 @@ export function TrainingStatistics() { -
+
+
diff --git a/apps/web/lib/fetch.api.ts b/apps/web/lib/fetch.api.ts index 822f741..287d1b6 100644 --- a/apps/web/lib/fetch.api.ts +++ b/apps/web/lib/fetch.api.ts @@ -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 >( url: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET', data?: any, + config?: AxiosRequestConfig, ): Promise< [{ type: string; message: string; details?: any } | null, z.infer | null] > => { @@ -40,6 +41,7 @@ export const safeFetchApi = async >( method, url, data, + ...config, }); const parsed = schema.safeParse(response.data);