4 Commits

13 changed files with 864 additions and 136 deletions

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,13 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Header,
Param, Param,
Patch, Patch,
Post, Post,
Query, Query,
Req, Req,
StreamableFile,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -24,6 +26,7 @@ import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto'; import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingService } from './training.service'; import { TrainingService } from './training.service';
import { Public } from '@/common/decorators';
@ApiTags('training') @ApiTags('training')
@Controller('training') @Controller('training')
@@ -76,6 +79,38 @@ export class TrainingController {
const data = await this.trainingService.getStatistics(filterDto); const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training statistics fetched successfully', data }; 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') @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 { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import * as schema from 'src/database/index'; 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 { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto'; import { CreateTrainingDto } from './dto/create-training.dto';
@@ -98,20 +103,30 @@ export class TrainingService {
if (municipalityId) if (municipalityId)
filters.push(eq(trainingSurveys.municipality, municipalityId)); filters.push(eq(trainingSurveys.municipality, municipalityId));
if (parishId) filters.push(eq(trainingSurveys.parish, parishId)); 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; const whereCondition = filters.length > 0 ? and(...filters) : undefined;
// Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad // Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad
const [ const [
totalOspsResult, totalOspsResult,
totalProducersResult, // totalProducersResult,
totalProductsResult, // Nuevo: Calculado desde el JSON totalProductsResult,
statusDistribution, statusDistribution,
activityDistribution, activityDistribution,
typeDistribution, typeDistribution,
stateDistribution, stateDistribution,
yearDistribution, yearDistribution,
ecoSectorDistribution,
productiveSectorDistribution,
centralActivityDistribution,
mainActivityDistribution,
structureTypeDistribution,
isOpenSpaceDistribution,
hasTransportDistribution,
genderResult,
municipalityDistribution,
parishDistribution,
] = await Promise.all([ ] = await Promise.all([
// 1. Total OSPs // 1. Total OSPs
this.drizzle this.drizzle
@@ -120,12 +135,12 @@ export class TrainingService {
.where(whereCondition), .where(whereCondition),
// 2. Total Productores (Columna plana que mantuviste) // 2. Total Productores (Columna plana que mantuviste)
this.drizzle // this.drizzle
.select({ // .select({
sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`, // sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
}) // })
.from(trainingSurveys) // .from(trainingSurveys)
.where(whereCondition), // .where(whereCondition),
// 3. NUEVO: Total Productos (Contamos el largo del array JSON productList) // 3. NUEVO: Total Productos (Contamos el largo del array JSON productList)
this.drizzle this.drizzle
@@ -145,7 +160,7 @@ export class TrainingService {
.where(whereCondition) .where(whereCondition)
.groupBy(trainingSurveys.currentStatus), .groupBy(trainingSurveys.currentStatus),
// 5. Distribución por Actividad // 5. Distribución por Actividad (General)
this.drizzle this.drizzle
.select({ .select({
name: trainingSurveys.productiveActivity, name: trainingSurveys.productiveActivity,
@@ -188,11 +203,115 @@ export class TrainingService {
.where(whereCondition) .where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear) .groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(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 { return {
totalOsps: Number(totalOspsResult[0]?.count || 0), 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 totalProducts: Number(totalProductsResult[0]?.sum || 0), // Dato extraído del JSON
statusDistribution: statusDistribution.map((item) => ({ statusDistribution: statusDistribution.map((item) => ({
@@ -215,6 +334,46 @@ export class TrainingService {
...item, ...item,
value: Number(item.value), 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(); // 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() { export default function SocioproductivaStatisticsPage() {
return ( return (
<PageContainer> // <PageContainer>
<div className="w-full"> <div className="w-full p-6">
<h1 className="text-2xl font-bold mb-6">Estadísticas Socioproductivas</h1> <h1 className="text-2xl font-bold mb-6">Estadísticas Socioproductivas</h1>
<TrainingStatistics /> <TrainingStatistics />
</div> </div>
</PageContainer> // </PageContainer>
); );
} }

View File

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

View File

@@ -37,6 +37,8 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { useTrainingStatsQuery } from '../hooks/use-training-statistics'; import { useTrainingStatsQuery } from '../hooks/use-training-statistics';
import { exportTrainingAction } from '../actions/training-actions';
import { Download } from 'lucide-react';
const OSP_TYPES = [ const OSP_TYPES = [
'EPSD', 'EPSD',
@@ -89,6 +91,38 @@ export function TrainingStatistics() {
setOspType(''); 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) { if (isLoading) {
return ( return (
<div className="flex justify-center p-8">Cargando estadísticas...</div> <div className="flex justify-center p-8">Cargando estadísticas...</div>
@@ -103,12 +137,22 @@ export function TrainingStatistics() {
const { const {
totalOsps, totalOsps,
totalProducers, // totalProducers,
statusDistribution, statusDistribution,
activityDistribution, activityDistribution,
typeDistribution, typeDistribution,
stateDistribution, stateDistribution,
yearDistribution, yearDistribution,
ecoSectorDistribution,
productiveSectorDistribution,
centralActivityDistribution,
mainActivityDistribution,
structureTypeDistribution,
isOpenSpaceDistribution,
hasTransportDistribution,
genderDistribution,
municipalityDistribution,
parishDistribution,
} = data; } = data;
const COLORS = [ const COLORS = [
@@ -206,10 +250,19 @@ export function TrainingStatistics() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-end"> <div className="flex items-end gap-2">
<Button variant="outline" onClick={handleClearFilters}> <Button variant="outline" onClick={handleClearFilters}>
Limpiar Filtros Limpiar Filtros
</Button> </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>
</div> </div>
</CardContent> </CardContent>
@@ -230,7 +283,8 @@ export function TrainingStatistics() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
{/* <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">
Total de Productores Total de Productores
@@ -242,7 +296,8 @@ export function TrainingStatistics() {
Productores asociados Productores asociados
</p> </p>
</CardContent> </CardContent>
</Card> </Card> */}
<Card className="col-span-full"> <Card className="col-span-full">
<CardHeader> <CardHeader>
@@ -269,8 +324,8 @@ export function TrainingStatistics() {
</CardContent> </CardContent>
</Card> </Card>
{/* State Distribution */} {/* Location Distribution (Dynamic) */}
{/* <Card className="col-span-full"> <Card className="col-span-full">
<CardHeader> <CardHeader>
<CardTitle>Distribución por Estado</CardTitle> <CardTitle>Distribución por Estado</CardTitle>
<CardDescription>OSP registradas por estado</CardDescription> <CardDescription>OSP registradas por estado</CardDescription>
@@ -290,7 +345,69 @@ export function TrainingStatistics() {
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </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 */} {/* Year Distribution */}
<Card className="col-span-full lg:col-span-1"> <Card className="col-span-full lg:col-span-1">
@@ -367,6 +484,223 @@ export function TrainingStatistics() {
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
</Card> </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>
</div> </div>
); );

View File

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

View File

@@ -10,13 +10,23 @@ export const statisticsItemSchema = z.object({
export const trainingStatisticsSchema = z.object({ export const trainingStatisticsSchema = z.object({
totalOsps: z.number(), totalOsps: z.number(),
totalProducers: z.number(), // totalProducers: z.number(),
totalProducts: z.number(), totalProducts: z.number(),
statusDistribution: z.array(statisticsItemSchema), statusDistribution: z.array(statisticsItemSchema),
activityDistribution: z.array(statisticsItemSchema), activityDistribution: z.array(statisticsItemSchema),
typeDistribution: z.array(statisticsItemSchema), typeDistribution: z.array(statisticsItemSchema),
stateDistribution: z.array(statisticsItemSchema), stateDistribution: z.array(statisticsItemSchema),
yearDistribution: 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>; export type TrainingStatisticsData = z.infer<typeof trainingStatisticsSchema>;

View File

@@ -1,6 +1,6 @@
'use server'; 'use server';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import axios, { InternalAxiosRequestConfig } from 'axios'; import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { z } from 'zod'; import { z } from 'zod';
// Crear instancia de Axios con la URL base validada // Crear instancia de Axios con la URL base validada
@@ -32,6 +32,7 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
url: string, url: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET', method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
data?: any, data?: any,
config?: AxiosRequestConfig,
): Promise< ): Promise<
[{ type: string; message: string; details?: any } | null, z.infer<T> | null] [{ 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, method,
url, url,
data, data,
...config,
}); });
const parsed = schema.safeParse(response.data); 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 { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from 'class-variance-authority';
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( 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: { variants: {
variant: { variant: {
default: 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: 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: 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: 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: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} },
) );
function Badge({ function Badge({
className, className,
variant, variant,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : 'span';
return ( return (
<Comp <Comp
@@ -40,7 +42,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };