Merge branch 'main' of ssh://git.fondemi.gob.ve:222/Fondemi/sistema_base

This commit is contained in:
2025-12-15 13:06:33 -04:00
33 changed files with 5573 additions and 227 deletions

View File

@@ -13,13 +13,13 @@ import { ThrottlerGuard } from '@nestjs/throttler';
import { DrizzleModule } from './database/drizzle.module';
import { AuthModule } from './features/auth/auth.module';
import { ConfigurationsModule } from './features/configurations/configurations.module';
import { LocationModule} from './features/location/location.module'
import { LocationModule } from './features/location/location.module'
import { MailModule } from './features/mail/mail.module';
import { RolesModule } from './features/roles/roles.module';
import { UserRolesModule } from './features/user-roles/user-roles.module';
import { SurveysModule } from './features/surveys/surveys.module';
import {InventoryModule} from './features/inventory/inventory.module'
import { PicturesModule } from './features/pictures/pictures.module';
import { InventoryModule } from './features/inventory/inventory.module';
import { TrainingModule } from './features/training/training.module';
@Module({
providers: [
@@ -61,7 +61,7 @@ import { PicturesModule } from './features/pictures/pictures.module';
SurveysModule,
LocationModule,
InventoryModule,
PicturesModule
TrainingModule
],
})
export class AppModule {}
export class AppModule { }

View File

@@ -0,0 +1,37 @@
CREATE TABLE "training_surveys" (
"id" serial PRIMARY KEY NOT NULL,
"firstname" text NOT NULL,
"lastname" text NOT NULL,
"visit_date" timestamp NOT NULL,
"productive_activity" text NOT NULL,
"financial_requirement_description" text NOT NULL,
"situr_code_commune" text NOT NULL,
"communal_council" text NOT NULL,
"situr_code_communal_council" text NOT NULL,
"osp_name" text NOT NULL,
"osp_address" text NOT NULL,
"osp_rif" text NOT NULL,
"osp_type" text NOT NULL,
"current_status" text NOT NULL,
"company_constitution_year" integer NOT NULL,
"producer_count" integer NOT NULL,
"product_description" text NOT NULL,
"installed_capacity" text NOT NULL,
"operational_capacity" text NOT NULL,
"osp_responsible_fullname" text NOT NULL,
"osp_responsible_cedula" text NOT NULL,
"osp_responsible_rif" text NOT NULL,
"osp_responsible_phone" text NOT NULL,
"civil_state" text NOT NULL,
"family_burden" integer NOT NULL,
"number_of_children" integer NOT NULL,
"general_observations" text NOT NULL,
"photo1" text NOT NULL,
"photo2" text NOT NULL,
"photo3" text NOT NULL,
"paralysis_reason" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE INDEX "training_surveys_index_00" ON "training_surveys" USING btree ("firstname");

View File

@@ -0,0 +1,7 @@
ALTER TABLE "training_surveys" ADD COLUMN "state" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "municipality" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "parish" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "osp_responsible_email" text NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_state_states_id_fk" FOREIGN KEY ("state") REFERENCES "public"."states"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_municipality_municipalities_id_fk" FOREIGN KEY ("municipality") REFERENCES "public"."municipalities"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_parish_parishes_id_fk" FOREIGN KEY ("parish") REFERENCES "public"."parishes"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,20 @@
"when": 1754420096323,
"tag": "0007_curved_fantastic_four",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1764623430844,
"tag": "0008_plain_scream",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1764883378610,
"tag": "0009_eminent_ares",
"breakpoints": true
}
]
}

View File

@@ -2,6 +2,7 @@ import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps';
import { users } from './auth';
import { states, municipalities, parishes } from './general';
// Tabla surveys
@@ -44,7 +45,57 @@ export const answersSurveys = t.pgTable(
}),
);
// Tabla training_surveys
export const trainingSurveys = t.pgTable(
'training_surveys',
{
// Datos basicos
id: t.serial('id').primaryKey(),
firstname: t.text('firstname').notNull(),
lastname: t.text('lastname').notNull(),
visitDate: t.timestamp('visit_date').notNull(),
// ubicacion
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
siturCodeCommune: t.text('situr_code_commune').notNull(),
communalCouncil: t.text('communal_council').notNull(),
siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(),
// datos del OSP (ORGANIZACIÓN SOCIOPRODUCTIVA)
ospName: t.text('osp_name').notNull(),
ospAddress: t.text('osp_address').notNull(),
ospRif: t.text('osp_rif').notNull(),
ospType: t.text('osp_type').notNull(),
productiveActivity: t.text('productive_activity').notNull(),
financialRequirementDescription: t.text('financial_requirement_description').notNull(),
currentStatus: t.text('current_status').notNull(),
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
producerCount: t.integer('producer_count').notNull(),
productDescription: t.text('product_description').notNull(),
installedCapacity: t.text('installed_capacity').notNull(),
operationalCapacity: t.text('operational_capacity').notNull(),
// datos del responsable
ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(),
ospResponsibleCedula: t.text('osp_responsible_cedula').notNull(),
ospResponsibleRif: t.text('osp_responsible_rif').notNull(),
ospResponsiblePhone: t.text('osp_responsible_phone').notNull(),
ospResponsibleEmail: t.text('osp_responsible_email').notNull(),
civilState: t.text('civil_state').notNull(),
familyBurden: t.integer('family_burden').notNull(),
numberOfChildren: t.integer('number_of_children').notNull(),
// datos adicionales
generalObservations: t.text('general_observations').notNull(),
paralysisReason: t.text('paralysis_reason').notNull(),
// fotos
photo1: t.text('photo1').notNull(),
photo2: t.text('photo2').notNull(),
photo3: t.text('photo3').notNull(),
...timestamps,
},
(trainingSurveys) => ({
trainingSurveysIndex: t.index('training_surveys_index_00').on(trainingSurveys.firstname),
})
);
export const viewSurveys = t.pgView('v_surveys', {
surverId: t.integer('survey_id'),

View File

@@ -4,8 +4,8 @@ import { Env, validateString } from '@/common/utils';
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
import {
@@ -24,14 +24,14 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { and, eq, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index';
import { sessions, users, roles, usersRole } from 'src/database/index';
import { roles, sessions, users, usersRole } from 'src/database/index';
import { Session } from './interfaces/session.interface';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class AuthService {
@@ -81,33 +81,43 @@ export class AuthService {
//Generate Tokens
async generateTokens(user: User): Promise<AuthTokensInterface> {
const accessTokenSecret = envs.access_token_secret ?? '';
const accessTokenExp = envs.access_token_expiration ?? '';
const refreshTokenSecret = envs.refresh_token_secret ?? '';
const refreshTokenExp = envs.refresh_token_expiration ?? '';
if (
!accessTokenSecret ||
!accessTokenExp ||
!refreshTokenSecret ||
!refreshTokenExp
) {
throw new Error('JWT environment variables are missing or invalid');
}
interface JwtPayload {
sub: number;
username: string;
}
const payload: JwtPayload = {
sub: Number(user?.id),
username: user.username ?? '',
};
const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.access_token_secret,
expiresIn: envs.access_token_expiration,
},
),
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.refresh_token_secret,
expiresIn: envs.refresh_token_expiration,
},
),
this.jwtService.signAsync(payload, {
secret: accessTokenSecret,
expiresIn: accessTokenExp,
} as JwtSignOptions),
this.jwtService.signAsync(payload, {
secret: refreshTokenSecret,
expiresIn: refreshTokenExp,
} as JwtSignOptions),
]);
return {
access_token,
refresh_token,
};
return { access_token, refresh_token };
}
//Generate OTP Code For Email Confirmation
@@ -138,7 +148,8 @@ export class AuthService {
userId: parseInt(userId),
expiresAt: sessionInput.expiresAt,
});
if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
if (session.rowCount === 0)
throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
return 'Session created successfully';
}
@@ -197,7 +208,6 @@ export class AuthService {
//Sign In User Account
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
const user = await this.validateUser(dto);
const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token);
@@ -270,7 +280,7 @@ export class AuthService {
console.log('refresh_token', token);
const validation = await this.jwtService.verifyAsync(token, {
secret
secret,
});
if (!validation) throw new UnauthorizedException('Invalid refresh token');
@@ -279,10 +289,7 @@ export class AuthService {
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, user_id),
eq(sessions.sessionToken, token)
)
and(eq(sessions.userId, user_id), eq(sessions.sessionToken, token)),
);
// console.log(session.length);
@@ -311,75 +318,83 @@ export class AuthService {
}
async singUp(createUserDto: SingUpUserDto): Promise<User> {
// Check if username or email exists
const data = await this.drizzle
// Check if username or email exists
const data = await this.drizzle
.select({
id: users.id,
username: users.username,
email: users.email,
})
.from(users)
.where(
or(
eq(users.username, createUserDto.username),
eq(users.email, createUserDto.email),
),
);
if (data.length > 0) {
if (data[0].username === createUserDto.username) {
throw new HttpException(
'Username already exists',
HttpStatus.BAD_REQUEST,
);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Hash the password
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// check if user role is admin
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({
id: users.id,
username: users.username,
email: users.email
email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
})
.from(users)
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email)));
if (data.length > 0) {
if (data[0].username === createUserDto.username) {
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
// Hash the password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({
id: users.id,
username: users.username,
email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
})
.from(users)
.leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
.where(eq(users.id, newUser.id));
return userWithRole;
})
.leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
.where(eq(users.id, newUser.id));
return userWithRole;
});
}
}

View File

@@ -1,19 +0,0 @@
import { Controller, Post, UploadedFiles, UseInterceptors, Body } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { PicturesService } from './pictures.service';
@Controller('pictures')
export class PicturesController {
constructor(private readonly picturesService: PicturesService) {}
@Post('upload')
@UseInterceptors(FilesInterceptor('urlImg'))
async uploadFile(@UploadedFiles() files: Express.Multer.File[], @Body() body: any) {
// Aquí puedes acceder a los campos del formulario
// console.log('Archivos:', files);
// console.log('Otros campos del formulario:', body);
const result = await this.picturesService.saveImages(files);
return { data: result };
}
}

View File

@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { PicturesController } from './pictures.controller';
import { PicturesService } from './pictures.service';
@Module({
controllers: [PicturesController],
providers: [PicturesService],
})
export class PicturesModule {}

View File

@@ -1,41 +0,0 @@
import { Injectable } from '@nestjs/common';
import { writeFile } from 'fs/promises';
import { join } from 'path';
@Injectable()
export class PicturesService {
/**
* Guarda una imagen en el directorio de imágenes.
* @param file - El archivo de imagen a guardar.
* @returns La ruta de la imagen guardada.
*/
async saveImages(file: Express.Multer.File[]): Promise<string[]> {
// Construye la ruta al directorio de imágenes.
const picturesPath = join(__dirname, '..', '..', '..', '..', 'uploads','pict');
console.log(picturesPath);
let images : string[] = [];
let count = 0;
file.forEach(async (file) => {
count++
// Crea un nombre de archivo único para la imagen.
const fileName = `${Date.now()}-${count}-${file.originalname}`;
images.push(fileName);
// console.log(fileName);
// Construye la ruta completa al archivo de imagen.
const filePath = join(picturesPath, fileName);
// Escribe el archivo de imagen en el disco.
await writeFile(filePath, file.buffer);
});
// Devuelve la ruta de la imagen guardada.
// return [file[0].originalname]
return images;
}
}

View File

@@ -0,0 +1,140 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, IsDateString, IsOptional } from 'class-validator';
export class CreateTrainingDto {
@ApiProperty()
@IsString()
firstname: string;
@ApiProperty()
@IsString()
lastname: string;
@ApiProperty()
@IsDateString()
visitDate: string;
@ApiProperty()
@IsString()
productiveActivity: string;
@ApiProperty()
@IsString()
financialRequirementDescription: string;
@ApiProperty()
@IsInt()
state: number;
@ApiProperty()
@IsInt()
municipality: number;
@ApiProperty()
@IsInt()
parish: number;
@ApiProperty()
@IsString()
siturCodeCommune: string;
@ApiProperty()
@IsString()
communalCouncil: string;
@ApiProperty()
@IsString()
siturCodeCommunalCouncil: string;
@ApiProperty()
@IsString()
ospName: string;
@ApiProperty()
@IsString()
ospAddress: string;
@ApiProperty()
@IsString()
ospRif: string;
@ApiProperty()
@IsString()
ospType: string;
@ApiProperty()
@IsString()
currentStatus: string;
@ApiProperty()
@IsInt()
companyConstitutionYear: number;
@ApiProperty()
@IsInt()
producerCount: number;
@ApiProperty()
@IsString()
productDescription: string;
@ApiProperty()
@IsString()
installedCapacity: string;
@ApiProperty()
@IsString()
operationalCapacity: string;
@ApiProperty()
@IsString()
ospResponsibleFullname: string;
@ApiProperty()
@IsString()
ospResponsibleCedula: string;
@ApiProperty()
@IsString()
ospResponsibleRif: string;
@ApiProperty()
@IsString()
ospResponsiblePhone: string;
@ApiProperty()
@IsString()
ospResponsibleEmail: string;
@ApiProperty()
@IsString()
civilState: string;
@ApiProperty()
@IsInt()
familyBurden: number;
@ApiProperty()
@IsInt()
numberOfChildren: number;
@ApiProperty()
@IsString()
generalObservations: string;
@ApiProperty()
@IsString()
photo1: string;
@ApiProperty()
@IsString()
photo2: string;
@ApiProperty()
@IsString()
photo3: string;
@ApiProperty()
@IsString()
paralysisReason: string;
}

View File

@@ -0,0 +1,38 @@
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class TrainingStatisticsFilterDto {
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsNumber()
stateId?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsNumber()
municipalityId?: number;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsNumber()
parishId?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
ospType?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTrainingDto } from './create-training.dto';
export class UpdateTrainingDto extends PartialType(CreateTrainingDto) { }

View File

@@ -0,0 +1,68 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
import { TrainingService } from './training.service';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
@ApiTags('training')
@Controller('training')
export class TrainingController {
constructor(private readonly trainingService: TrainingService) { }
@Get()
@ApiOperation({ summary: 'Get all training records with pagination and filters' })
@ApiResponse({ status: 200, description: 'Return paginated training records.' })
async findAll(@Query() paginationDto: PaginationDto) {
const result = await this.trainingService.findAll(paginationDto);
return {
message: 'Training records fetched successfully',
data: result.data,
meta: result.meta
};
}
@Get('statistics')
@ApiOperation({ summary: 'Get training statistics' })
@ApiResponse({ status: 200, description: 'Return training statistics.' })
async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) {
const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training statistics fetched successfully', data };
}
@Get(':id')
@ApiOperation({ summary: 'Get a training record by ID' })
@ApiResponse({ status: 200, description: 'Return the training record.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async findOne(@Param('id') id: string) {
const data = await this.trainingService.findOne(+id);
return { message: 'Training record fetched successfully', data };
}
@Post()
@ApiOperation({ summary: 'Create a new training record' })
@ApiResponse({ status: 201, description: 'Training record created successfully.' })
async create(@Body() createTrainingDto: CreateTrainingDto) {
const data = await this.trainingService.create(createTrainingDto);
return { message: 'Training record created successfully', data };
}
@Patch(':id')
@ApiOperation({ summary: 'Update a training record' })
@ApiResponse({ status: 200, description: 'Training record updated successfully.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async update(@Param('id') id: string, @Body() updateTrainingDto: UpdateTrainingDto) {
const data = await this.trainingService.update(+id, updateTrainingDto);
return { message: 'Training record updated successfully', data };
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a training record' })
@ApiResponse({ status: 200, description: 'Training record deleted successfully.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async remove(@Param('id') id: string) {
return await this.trainingService.remove(+id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TrainingService } from './training.service';
import { TrainingController } from './training.controller';
@Module({
controllers: [TrainingController],
providers: [TrainingService],
exports: [TrainingService],
})
export class TrainingModule { }

View File

@@ -0,0 +1,223 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index';
import { trainingSurveys } from 'src/database/index';
import { eq, like, or, and, gte, lte, SQL, sql } from 'drizzle-orm';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { states } from 'src/database/index';
import { PaginationDto } from '../../common/dto/pagination.dto';
@Injectable()
export class TrainingService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { }
async findAll(paginationDto?: PaginationDto) {
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
const offset = (page - 1) * limit;
let searchCondition: SQL<unknown> | undefined;
if (search) {
searchCondition = or(
like(trainingSurveys.firstname, `%${search}%`),
like(trainingSurveys.lastname, `%${search}%`),
like(trainingSurveys.ospName, `%${search}%`),
like(trainingSurveys.ospRif, `%${search}%`)
);
}
const orderBy = sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
const data = await this.drizzle
.select()
.from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto;
const filters: SQL[] = [];
if (startDate) {
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
}
if (endDate) {
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
}
if (stateId) {
filters.push(eq(trainingSurveys.state, stateId));
}
if (municipalityId) {
filters.push(eq(trainingSurveys.municipality, municipalityId));
}
if (parishId) {
filters.push(eq(trainingSurveys.parish, parishId));
}
if (ospType) {
filters.push(eq(trainingSurveys.ospType, ospType));
}
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
const totalOspsResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition);
const totalOsps = Number(totalOspsResult[0].count);
const totalProducersResult = await this.drizzle
.select({ sum: sql<number>`sum(${trainingSurveys.producerCount})` })
.from(trainingSurveys)
.where(whereCondition);
const totalProducers = Number(totalProducersResult[0].sum || 0);
const statusDistribution = await this.drizzle
.select({
name: trainingSurveys.currentStatus,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus);
const activityDistribution = await this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveActivity);
const typeDistribution = await this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ospType);
// New Aggregations
const stateDistribution = await this.drizzle
.select({
name: states.name,
value: sql<number>`count(${trainingSurveys.id})`
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.where(whereCondition)
.groupBy(states.name);
const yearDistribution = await this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear);
return {
totalOsps,
totalProducers,
statusDistribution: statusDistribution.map(item => ({ ...item, value: Number(item.value) })),
activityDistribution: activityDistribution.map(item => ({ ...item, value: Number(item.value) })),
typeDistribution: typeDistribution.map(item => ({ ...item, value: Number(item.value) })),
stateDistribution: stateDistribution.map(item => ({ ...item, value: Number(item.value) })),
yearDistribution: yearDistribution.map(item => ({ ...item, value: Number(item.value) })),
};
}
async findOne(id: number) {
const find = await this.drizzle
.select()
.from(trainingSurveys)
.where(eq(trainingSurveys.id, id));
if (find.length === 0) {
throw new HttpException('Training record not found', HttpStatus.NOT_FOUND);
}
return find[0];
}
async create(createTrainingDto: CreateTrainingDto) {
const [newRecord] = await this.drizzle
.insert(trainingSurveys)
.values({
...createTrainingDto,
visitDate: new Date(createTrainingDto.visitDate),
})
.returning();
return newRecord;
}
async update(id: number, updateTrainingDto: UpdateTrainingDto) {
await this.findOne(id);
const updateData: any = { ...updateTrainingDto };
if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(updateTrainingDto.visitDate);
}
const [updatedRecord] = await this.drizzle
.update(trainingSurveys)
.set(updateData)
.where(eq(trainingSurveys.id, id))
.returning();
return updatedRecord;
}
async remove(id: number) {
await this.findOne(id);
const [deletedRecord] = await this.drizzle
.delete(trainingSurveys)
.where(eq(trainingSurveys.id, id))
.returning();
return { message: 'Training record deleted successfully', data: deletedRecord };
}
}

View File

@@ -1,19 +0,0 @@
import PageContainer from '@/components/layout/page-container';
const Page = () => {
return (
<PageContainer>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
En mantenimiento
</div>
</PageContainer>
// <div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
// <div className="flex w-full max-w-sm flex-col gap-6">
// </div>
// </div>
);
};
export default Page;

View File

@@ -0,0 +1,19 @@
import PageContainer from '@/components/layout/page-container';
import { TrainingStatistics } from '@/feactures/training/components/training-statistics';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Estadísticas Socioproductivas - Fondemi',
description: 'Análisis y estadísticas de las Organizaciones Socioproductivas',
};
export default function SocioproductivaStatisticsPage() {
return (
<PageContainer>
<div className="w-full">
<h1 className="text-2xl font-bold mb-6">Estadísticas Socioproductivas</h1>
<TrainingStatistics />
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { CreateTrainingForm } from '@/feactures/training/components/form';
const Page = () => {
return (
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<CreateTrainingForm />
</div>
);
};
export default Page;

View File

@@ -1,7 +1,7 @@
'use client';
import { NavMain as GeneralMain, NavMain as AdministrationMain, NavMain as StatisticsMain, } from '@/components/nav-main';
import { GeneralItems, AdministrationItems, StatisticsItems } from '@/constants/data';
import { NavMain as GeneralMain, NavMain as AdministrationMain, NavMain as StatisticsMain, } from '@/components/nav-main';
import { GeneralItems, AdministrationItems, StatisticsItems } from '@/constants/routes';
import {
Sidebar,
SidebarContent,
@@ -24,7 +24,7 @@ export const company = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession();
const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol :'';
const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol : '';
// console.log(AdministrationItems[0]?.role);
return (
@@ -42,14 +42,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
</SidebarHeader>
<SidebarContent>
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole}/>
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole} />
{StatisticsItems[0]?.role?.includes(userRole) &&
<StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole}/>
<StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole} />
}
{AdministrationItems[0]?.role?.includes(userRole) &&
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole}/>
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole} />
}
{/* <NavProjects projects={data.projects} /> */}
</SidebarContent>

View File

@@ -20,14 +20,13 @@ export const GeneralItems: NavItem[] = [
},
];
export const AdministrationItems: NavItem[] = [
{
title: 'Administracion',
url: '#', // Placeholder as there is no direct link for the parent
icon: 'settings2',
isActive: true,
role:['admin','superadmin','manager','user'], // sumatoria de los roles que si tienen acceso
role: ['admin', 'superadmin', 'manager', 'autoridad'], // sumatoria de los roles que si tienen acceso
items: [
{
@@ -35,14 +34,21 @@ export const AdministrationItems: NavItem[] = [
url: '/dashboard/administracion/usuario',
icon: 'userPen',
shortcut: ['m', 'm'],
role:['admin','superadmin'],
role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'Encuestas',
shortcut: ['l', 'l'],
url: '/dashboard/administracion/encuestas',
icon: 'login',
role:['admin','superadmin','manager','user'],
role: ['admin', 'superadmin', 'autoridad', 'manager'],
},
{
title: 'Registro OSP',
shortcut: ['p', 'p'],
url: '/dashboard/formulario/',
icon: 'notepadText',
role: ['admin', 'superadmin', 'manager', 'autoridad'],
},
],
},
@@ -54,7 +60,7 @@ export const StatisticsItems: NavItem[] = [
url: '#', // Placeholder as there is no direct link for the parent
icon: 'chartColumn',
isActive: true,
role:['admin','superadmin','autoridad'],
role: ['admin', 'superadmin', 'autoridad'],
items: [
// {
@@ -69,13 +75,15 @@ export const StatisticsItems: NavItem[] = [
shortcut: ['l', 'l'],
url: '/dashboard/estadisticas/encuestas',
icon: 'notepadText',
role:['admin','superadmin','autoridad'],
role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'OSP',
shortcut: ['s', 's'],
url: '/dashboard/estadisticas/socioproductiva',
icon: 'blocks',
role: ['admin', 'superadmin', 'autoridad'],
},
],
},
];

View File

@@ -1,13 +1,14 @@
'use client';
import { DataTable } from '@repo/shadcn/table/data-table';
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
import { columns } from './product-tables/columns';
import { useProductQuery } from '../../hooks/use-query-products';
import { columns } from './product-tables/columns';
interface dataListProps {
initialPage: number;
initialSearch?: string | null;
initialLimit: number;
initialType?: string | null;
}
export default function UsersAdminList({
@@ -19,9 +20,9 @@ export default function UsersAdminList({
page: initialPage,
limit: initialLimit,
...(initialSearch && { search: initialSearch }),
}
};
const {data, isLoading} = useProductQuery(filters)
const { data, isLoading } = useProductQuery(filters);
// console.log(data?.data);

View File

@@ -0,0 +1,123 @@
'use server';
import { safeFetchApi } from '@/lib/fetch.api';
import {
TrainingSchema,
TrainingMutate,
trainingApiResponseSchema
} from '../schemas/training';
import { trainingStatisticsResponseSchema } from '../schemas/statistics';
export const getTrainingStatisticsAction = async (params: {
startDate?: string;
endDate?: string;
stateId?: number;
municipalityId?: number;
parishId?: number;
ospType?: string;
} = {}) => {
const searchParams = new URLSearchParams();
if (params.startDate) searchParams.append('startDate', params.startDate);
if (params.endDate) searchParams.append('endDate', params.endDate);
if (params.stateId) searchParams.append('stateId', params.stateId.toString());
if (params.municipalityId) searchParams.append('municipalityId', params.municipalityId.toString());
if (params.parishId) searchParams.append('parishId', params.parishId.toString());
if (params.ospType) searchParams.append('ospType', params.ospType);
const [error, response] = await safeFetchApi(
trainingStatisticsResponseSchema,
`/training/statistics?${searchParams.toString()}`,
'GET',
);
if (error) throw new Error(error.message);
return response?.data;
}
export const getTrainingAction = async (params: {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
...(params.search && { search: params.search }),
...(params.sortBy && { sortBy: params.sortBy }),
...(params.sortOrder && { sortOrder: params.sortOrder }),
});
const [error, response] = await safeFetchApi(
trainingApiResponseSchema,
`/training?${searchParams}`,
'GET',
);
if (error) throw new Error(error.message);
return {
data: response?.data || [],
meta: response?.meta || {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 1,
hasNextPage: false,
hasPreviousPage: false,
nextPage: null,
previousPage: null,
},
};
}
export const createTrainingAction = async (payload: TrainingSchema) => {
const { id, ...payloadWithoutId } = payload;
const [error, data] = await safeFetchApi(
TrainingMutate,
'/training',
'POST',
payloadWithoutId,
);
if (error) {
throw new Error(error.message || 'Error al crear el registro');
}
return data;
};
export const updateTrainingAction = async (payload: TrainingSchema) => {
const { id, ...payloadWithoutId } = payload;
if (!id) throw new Error('ID es requerido para actualizar');
const [error, data] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'PATCH',
payloadWithoutId,
);
if (error) {
throw new Error(error.message || 'Error al actualizar el registro');
}
return data;
};
export const deleteTrainingAction = async (id: number) => {
const [error] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'DELETE'
)
if (error) throw new Error(error.message || 'Error al eliminar el registro');
return true;
}

View File

@@ -0,0 +1,41 @@
-- datos basicos
nombre,
apellido,
fecha de la visita,
-->Falta
hora de la visita,
-- datos de la ubicacion
estado,
municipio,
parroquia,
nombre de la comuna,
CODIGO SITUR COMUNA,
CONSEJO COMUNAL,
CODIGO SITUR CONSEJO COMUNAL,
-- datos de la osp
actividad productiva (agricola,textil,bloquera,carpinteria,unidad de suministro),
realice una breve descripcion del requerimiento financiero,
NOMBRE DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
DIRECCIÓN DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
RIF DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
TIPO DE ORGANIZACIÓN SOCIOPRODUCTIVA,
ESTATUS ACTUAL,
AÑO DE CONSTITUCIÓN DE LA EMPRESA ,
CANTIDAD DE PRODUCTORES QUE LA CONFORMAN,
BREVE DESCRIPCIÓN DEL PRODUCTO O SERVICIO QUE OFRECE,
CAPACIDAD INSTALADA,
CAPACIDAD OPERATIVA,
¿EXPLIQUE LAS RAZONES GENERALES POR LAS CUALES LA UNIDAD DE PRODUCCIÓN TUVO QUE PARALIZARSE?
-- datos del responsable
NOMBRE Y APELLIDO DEL RESPONSABLE DE LA OSP,
CÉDULA DEL RESPONSABLE (SIN PUNTOS),
RIF DEL RESPONSABLE (SIN PUNTOS),
TELÉFONOS (COLOQUE 2 NUMEROS DE TELEFONOS),
CORREO ELECTRÓNICO,
ESTADO CIVIL DEL PRODUCTOR,
CARGA FAMILIAR,
NUMERO DE HIJOS,
-- datos adicionales
OBSERVACIONES GENERALES,
-- fotos
COLOCAR TRES (3) REGISTROS FOTOGRÁFICOS VISIBLES DEL ESPACIO Y MAQUINARIAS ACTUALMENTE (OBLIGATORIO),

View File

@@ -0,0 +1,558 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@repo/shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@repo/shadcn/form';
import { Input } from '@repo/shadcn/input';
import { Textarea } from '@repo/shadcn/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { useForm } from 'react-hook-form';
import { useCreateTraining } from "../hooks/use-training";
import { TrainingSchema, trainingSchema } from '../schemas/training';
import { SelectSearchable } from '@repo/shadcn/select-searchable'
import React from 'react';
import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location';
const PRODUCTIVE_ACTIVITIES = [
'Agricola',
'Textil',
'Bloquera',
'Carpinteria',
'Unidad de suministro'
];
interface CreateTrainingFormProps {
onSuccess?: () => void;
onCancel?: () => void;
defaultValues?: Partial<TrainingSchema>;
}
export function CreateTrainingForm({
onSuccess,
onCancel,
defaultValues,
}: CreateTrainingFormProps) {
const {
mutate: saveTraining,
isPending: isSaving,
} = useCreateTraining();
const [state, setState] = React.useState(0);
const [municipality, setMunicipality] = React.useState(0);
const [disabledMunicipality, setDisabledMunicipality] = React.useState(true);
const [disabledParish, setDisabledParish] = React.useState(true);
const { data: dataState } = useStateQuery()
const { data: dataMunicipality } = useMunicipalityQuery(state)
const { data: dataParish } = useParishQuery(municipality)
const stateOptions = dataState?.data || [{ id: 0, name: 'Sin estados' }]
const municipalityOptions = Array.isArray(dataMunicipality?.data) && dataMunicipality.data.length > 0
? dataMunicipality.data
: [{ id: 0, stateId: 0, name: 'Sin Municipios' }]
// const parishOptions = dataParish?.data || [{id:0,municipalityId:0,name:'Sin Parroquias'}]
const parishOptions = Array.isArray(dataParish?.data) && dataParish.data.length > 0
? dataParish.data
: [{ id: 0, stateId: 0, name: 'Sin Parroquias' }]
const form = useForm<TrainingSchema>({
resolver: zodResolver(trainingSchema),
defaultValues: {
firstname: defaultValues?.firstname || '',
lastname: defaultValues?.lastname || '',
visitDate: defaultValues?.visitDate || new Date().toISOString().split('T')[0],
productiveActivity: defaultValues?.productiveActivity || '',
financialRequirementDescription: defaultValues?.financialRequirementDescription || '',
siturCodeCommune: defaultValues?.siturCodeCommune || '',
communalCouncil: defaultValues?.communalCouncil || '',
siturCodeCommunalCouncil: defaultValues?.siturCodeCommunalCouncil || '',
ospName: defaultValues?.ospName || '',
ospAddress: defaultValues?.ospAddress || '',
ospRif: defaultValues?.ospRif || '',
ospType: defaultValues?.ospType || '',
currentStatus: defaultValues?.currentStatus || '',
companyConstitutionYear: defaultValues?.companyConstitutionYear || new Date().getFullYear(),
producerCount: defaultValues?.producerCount || 0,
productDescription: defaultValues?.productDescription || '',
installedCapacity: defaultValues?.installedCapacity || '',
operationalCapacity: defaultValues?.operationalCapacity || '',
ospResponsibleFullname: defaultValues?.ospResponsibleFullname || '',
ospResponsibleCedula: defaultValues?.ospResponsibleCedula || '',
ospResponsibleRif: defaultValues?.ospResponsibleRif || '',
ospResponsiblePhone: defaultValues?.ospResponsiblePhone || '',
civilState: defaultValues?.civilState || '',
familyBurden: defaultValues?.familyBurden || 0,
numberOfChildren: defaultValues?.numberOfChildren || 0,
generalObservations: defaultValues?.generalObservations || '',
ospResponsibleEmail: defaultValues?.ospResponsibleEmail || '',
photo1: defaultValues?.photo1 || '',
photo2: defaultValues?.photo2 || '',
photo3: defaultValues?.photo3 || '',
paralysisReason: defaultValues?.paralysisReason || '',
state: defaultValues?.state || undefined,
municipality: defaultValues?.municipality || undefined,
parish: defaultValues?.parish || undefined,
},
mode: 'onChange',
});
const onSubmit = async (formData: TrainingSchema) => {
saveTraining(formData, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
onError: (e) => {
console.error(e);
form.setError('root', {
type: 'manual',
message: 'Error al guardar el registro',
});
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{form.formState.errors.root && (
<div className="text-destructive text-sm">
{form.formState.errors.root.message}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Datos Personales */}
<div className="col-span-2">
<h3 className="text-lg font-medium mb-2">Datos Básicos</h3>
</div>
<FormField control={form.control} name="firstname" render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="lastname" render={({ field }) => (
<FormItem>
<FormLabel>Apellido</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="visitDate" render={({ field }) => (
<FormItem>
<FormLabel>Fecha de la visita</FormLabel>
<FormControl>
<Input
type="date"
value={field.value ? new Date(field.value).toISOString().split('T')[0] : ''}
onChange={(e) => {
// Convert YYYY-MM-DD to ISO 8601 string
const dateValue = e.target.value;
if (dateValue) {
field.onChange(new Date(dateValue).toISOString());
} else {
field.onChange('');
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
{/* Ubicación */}
<div className="col-span-2">
<h3 className="text-lg font-medium mb-2 mt-4">Ubicación</h3>
</div>
<FormField
control={form.control}
name="state"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Estado</FormLabel>
<SelectSearchable
options={
stateOptions?.map((item) => ({
value: item.id.toString(),
label: item.name,
})) || []
}
onValueChange={(value: any) => { field.onChange(Number(value)); setState(value); setDisabledMunicipality(false); setDisabledParish(true) }
}
placeholder="Selecciona un estado"
defaultValue={field.value?.toString()}
// disabled={readOnly}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="municipality"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Municipio</FormLabel>
<SelectSearchable
options={
municipalityOptions?.map((item) => ({
value: item.id.toString(),
label: item.name,
})) || []
}
onValueChange={(value: any) => { field.onChange(Number(value)); setMunicipality(value); setDisabledParish(false) }
}
placeholder="Selecciona un Municipio"
defaultValue={field.value?.toString()}
disabled={disabledMunicipality}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="parish"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Parroquia</FormLabel>
<SelectSearchable
options={
parishOptions?.map((item) => ({
value: item.id.toString(),
label: item.name,
})) || []
}
onValueChange={(value: any) =>
field.onChange(Number(value))
}
placeholder="Selecciona una Parroquia"
defaultValue={field.value?.toString()}
disabled={disabledParish}
/>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField control={form.control} name="state" render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
<FormControl><Input {...field} value={field.value || ''} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="municipality" render={({ field }) => (
<FormItem>
<FormLabel>Municipio</FormLabel>
<FormControl><Input {...field} value={field.value || ''} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="parish" render={({ field }) => (
<FormItem>
<FormLabel>Parroquia</FormLabel>
<FormControl><Input {...field} value={field.value || ''} /></FormControl>
<FormMessage />
</FormItem>
)} /> */}
<FormField control={form.control} name="siturCodeCommune" render={({ field }) => (
<FormItem>
<FormLabel>Código SITUR Comuna</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="communalCouncil" render={({ field }) => (
<FormItem>
<FormLabel>Consejo Comunal</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="siturCodeCommunalCouncil" render={({ field }) => (
<FormItem>
<FormLabel>Código SITUR Consejo Comunal</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{/* Datos de la OSP */}
<div className="col-span-2">
<h3 className="text-lg font-medium mb-2 mt-4">Datos de la Organización Socioproductiva (OSP)</h3>
</div>
<FormField control={form.control} name="ospName" render={({ field }) => (
<FormItem>
<FormLabel>Nombre de la Organización</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospAddress" render={({ field }) => (
<FormItem>
<FormLabel>Dirección</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospRif" render={({ field }) => (
<FormItem>
<FormLabel>RIF</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospType" render={({ field }) => (
<FormItem>
<FormLabel>Tipo de Organización</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="productiveActivity" render={({ field }) => (
<FormItem>
<FormLabel>Actividad Productiva</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleccione actividad" />
</SelectTrigger>
</FormControl>
<SelectContent>
{PRODUCTIVE_ACTIVITIES.map((activity) => (
<SelectItem key={activity} value={activity}>
{activity}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="currentStatus" render={({ field }) => (
<FormItem>
<FormLabel>Estatus Actual</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="companyConstitutionYear" render={({ field }) => (
<FormItem>
<FormLabel>Año de Constitución</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="producerCount" render={({ field }) => (
<FormItem>
<FormLabel>Cantidad de Productores</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="productDescription" render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Breve descripción del producto o servicio</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="installedCapacity" render={({ field }) => (
<FormItem>
<FormLabel>Capacidad Instalada</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="operationalCapacity" render={({ field }) => (
<FormItem>
<FormLabel>Capacidad Operativa</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="financialRequirementDescription" render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Descripción del Requerimiento Financiero</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="paralysisReason" render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Razones de paralización (si aplica)</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{/* Responsable */}
<div className="col-span-2">
<h3 className="text-lg font-medium mb-2 mt-4">Datos del Responsable</h3>
</div>
<FormField control={form.control} name="ospResponsibleFullname" render={({ field }) => (
<FormItem>
<FormLabel>Nombre y Apellido</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospResponsibleCedula" render={({ field }) => (
<FormItem>
<FormLabel>Cédula (sin puntos)</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospResponsibleRif" render={({ field }) => (
<FormItem>
<FormLabel>RIF (sin puntos)</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospResponsiblePhone" render={({ field }) => (
<FormItem>
<FormLabel>Teléfonos (2 números)</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="civilState" render={({ field }) => (
<FormItem>
<FormLabel>Estado Civil</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="ospResponsibleEmail" render={({ field }) => (
<FormItem>
<FormLabel>Correo Electrónico</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="familyBurden" render={({ field }) => (
<FormItem>
<FormLabel>Carga Familiar</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="numberOfChildren" render={({ field }) => (
<FormItem>
<FormLabel>Número de Hijos</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{/* datos adicionales */}
<div className="col-span-2">
<h3 className="text-lg font-medium mb-2 mt-4">Datos Adicionales</h3>
</div>
<FormField control={form.control} name="generalObservations" render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Observaciones Generales</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
{/* Fotos */}
<div className="col-span-2">
<h3 className="text-lg font-medium mb-2 mt-4">Registro Fotográfico (URLs)</h3>
</div>
<FormField control={form.control} name="photo1" render={({ field }) => (
<FormItem>
<FormLabel>Foto 1</FormLabel>
<FormControl><Input {...field} placeholder="URL de la imagen" /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="photo2" render={({ field }) => (
<FormItem>
<FormLabel>Foto 2</FormLabel>
<FormControl><Input {...field} placeholder="URL de la imagen" /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="photo3" render={({ field }) => (
<FormItem>
<FormLabel>Foto 3</FormLabel>
<FormControl><Input {...field} placeholder="URL de la imagen" /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="flex justify-end gap-4 mt-6">
<Button variant="outline" type="button" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,319 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { useTrainingStatsQuery } from '../hooks/use-training-statistics';
import { Input } from '@repo/shadcn/input';
import { Button } from '@repo/shadcn/button';
import { SelectSearchable } from '@repo/shadcn/select-searchable';
import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
const OSP_TYPES = [
'EPSD',
'EPSI',
'UPF',
'Cooperativa',
'Grupo de Intercambio',
];
export function TrainingStatistics() {
// Filter State
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stateId, setStateId] = useState<number>(0);
const [municipalityId, setMunicipalityId] = useState<number>(0);
const [parishId, setParishId] = useState<number>(0);
const [ospType, setOspType] = useState<string>('');
// Location Data
const { data: dataState } = useStateQuery();
const { data: dataMunicipality } = useMunicipalityQuery(stateId);
const { data: dataParish } = useParishQuery(municipalityId);
const stateOptions = dataState?.data || [{ id: 0, name: 'Sin estados' }];
const municipalityOptions = Array.isArray(dataMunicipality?.data) && dataMunicipality.data.length > 0
? dataMunicipality.data
: [{ id: 0, stateId: 0, name: 'Sin Municipios' }];
const parishOptions = Array.isArray(dataParish?.data) && dataParish.data.length > 0
? dataParish.data
: [{ id: 0, stateId: 0, name: 'Sin Parroquias' }];
// Query with Filters
const { data, isLoading, refetch } = useTrainingStatsQuery({
startDate: startDate || undefined,
endDate: endDate || undefined,
stateId: stateId || undefined,
municipalityId: municipalityId || undefined,
parishId: parishId || undefined,
ospType: ospType || undefined,
});
const handleClearFilters = () => {
setStartDate('');
setEndDate('');
setStateId(0);
setMunicipalityId(0);
setParishId(0);
setOspType('');
};
if (isLoading) {
return <div className="flex justify-center p-8">Cargando estadísticas...</div>;
}
if (!data) {
return <div className="flex justify-center p-8">No hay datos disponibles.</div>;
}
const { totalOsps, totalProducers, statusDistribution, activityDistribution, typeDistribution, stateDistribution, yearDistribution } = data;
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'];
return (
<div className="space-y-6">
{/* Filters Section */}
<Card>
<CardHeader>
<CardTitle>Filtros</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Fecha Inicio</label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Fecha Fin</label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Estado</label>
<SelectSearchable
options={stateOptions.map((item) => ({
value: item.id.toString(),
label: item.name,
}))}
onValueChange={(value: any) => {
setStateId(Number(value));
setMunicipalityId(0); // Reset municipality
setParishId(0); // Reset parish
}}
placeholder="Selecciona un estado"
defaultValue={stateId ? stateId.toString() : ""}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Municipio</label>
<SelectSearchable
options={municipalityOptions.map((item) => ({
value: item.id.toString(),
label: item.name,
}))}
onValueChange={(value: any) => {
setMunicipalityId(Number(value));
setParishId(0);
}}
placeholder="Selecciona municipio"
defaultValue={municipalityId ? municipalityId.toString() : ""}
disabled={!stateId || stateId === 0}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Parroquia</label>
<SelectSearchable
options={parishOptions.map((item) => ({
value: item.id.toString(),
label: item.name,
}))}
onValueChange={(value: any) => setParishId(Number(value))}
placeholder="Selecciona parroquia"
defaultValue={parishId ? parishId.toString() : ""}
disabled={!municipalityId || municipalityId === 0}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Tipo de OSP</label>
<Select value={ospType} onValueChange={setOspType}>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{OSP_TYPES.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button variant="outline" onClick={handleClearFilters}>
Limpiar Filtros
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Statistics Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total de OSP Registradas</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalOsps}</div>
<p className="text-xs text-muted-foreground">
Organizaciones Socioproductivas
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total de Productores</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProducers}</div>
<p className="text-xs text-muted-foreground">
Productores asociados
</p>
</CardContent>
</Card>
<Card className="col-span-full">
<CardHeader>
<CardTitle>Actividad Productiva</CardTitle>
<CardDescription>Distribución por tipo de actividad</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={activityDistribution}
layout="vertical"
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" width={150} />
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
<Bar dataKey="value" fill="#8884d8" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* State Distribution */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Distribución por Estado</CardTitle>
<CardDescription>OSP registradas por estado</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={stateDistribution}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
<Bar dataKey="value" fill="#00C49F" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Year Distribution */}
<Card className="col-span-full lg:col-span-1">
<CardHeader>
<CardTitle>Año de Constitución</CardTitle>
<CardDescription>Año de registro de la empresa</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={yearDistribution}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
<Bar dataKey="value" fill="#FFBB28" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="col-span-1 md:col-span-2 lg:col-span-1">
<CardHeader>
<CardTitle>Estatus Actual</CardTitle>
<CardDescription>Estado operativo de las OSP</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statusDistribution}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{statusDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="col-span-1 md:col-span-2 lg:col-span-1">
<CardHeader>
<CardTitle>Tipo de Organización</CardTitle>
<CardDescription>Clasificación de las OSP</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={typeDistribution}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip wrapperStyle={{ color: '#000' }} />
<Legend />
<Bar dataKey="value" fill="#82ca9d" name="Cantidad" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { useSafeQuery } from '@/hooks/use-safe-query';
import { getTrainingStatisticsAction } from '../actions/training-actions';
export function useTrainingStatsQuery(params: {
startDate?: string;
endDate?: string;
stateId?: number;
municipalityId?: number;
parishId?: number;
ospType?: string;
} = {}) {
return useSafeQuery(['training-statistics', JSON.stringify(params)], () => getTrainingStatisticsAction(params));
}

View File

@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { TrainingSchema } from "../schemas/training";
import { createTrainingAction, updateTrainingAction, deleteTrainingAction } from "../actions/training-actions";
export function useCreateTraining() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: TrainingSchema) => createTrainingAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
})
return mutation
}
export function useUpdateTraining() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: TrainingSchema) => updateTrainingAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
})
return mutation;
}
export function useDeleteTraining() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteTrainingAction(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
})
}

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
export const statisticsItemSchema = z.object({
name: z.string(),
value: z.number(),
});
export const trainingStatisticsSchema = z.object({
totalOsps: z.number(),
totalProducers: z.number(),
statusDistribution: z.array(statisticsItemSchema),
activityDistribution: z.array(statisticsItemSchema),
typeDistribution: z.array(statisticsItemSchema),
stateDistribution: z.array(statisticsItemSchema),
yearDistribution: z.array(statisticsItemSchema),
});
export type TrainingStatisticsData = z.infer<typeof trainingStatisticsSchema>;
export const trainingStatisticsResponseSchema = z.object({
message: z.string(),
data: trainingStatisticsSchema,
});

View File

@@ -0,0 +1,61 @@
import { z } from 'zod';
export const trainingSchema = z.object({
id: z.number().optional(),
firstname: z.string().min(1, { message: "Nombre es requerido" }),
lastname: z.string().min(1, { message: "Apellido es requerido" }),
visitDate: z.string().or(z.date()).transform((val) => new Date(val).toISOString()),
productiveActivity: z.string().min(1, { message: "Actividad productiva es requerida" }),
financialRequirementDescription: z.string().min(1, { message: "Descripción es requerida" }),
siturCodeCommune: z.string().min(1, { message: "Código SITUR Comuna es requerido" }),
communalCouncil: z.string().min(1, { message: "Consejo Comunal es requerido" }),
siturCodeCommunalCouncil: z.string().min(1, { message: "Código SITUR Consejo Comunal es requerido" }),
ospName: z.string().min(1, { message: "Nombre de la OSP es requerido" }),
ospAddress: z.string().min(1, { message: "Dirección de la OSP es requerida" }),
ospRif: z.string().min(1, { message: "RIF de la OSP es requerido" }),
ospType: z.string().min(1, { message: "Tipo de OSP es requerido" }),
currentStatus: z.string().min(1, { message: "Estatus actual es requerido" }),
companyConstitutionYear: z.coerce.number().min(1900, { message: "Año inválido" }),
producerCount: z.coerce.number().min(1, { message: "Cantidad de productores requerida" }),
productDescription: z.string().min(1, { message: "Descripción del producto es requerida" }),
installedCapacity: z.string().min(1, { message: "Capacidad instalada es requerida" }),
operationalCapacity: z.string().min(1, { message: "Capacidad operativa es requerida" }),
ospResponsibleFullname: z.string().min(1, { message: "Nombre del responsable es requerido" }),
ospResponsibleCedula: z.string().min(1, { message: "Cédula del responsable es requerida" }),
ospResponsibleRif: z.string().min(1, { message: "RIF del responsable es requerido" }),
ospResponsiblePhone: z.string().min(1, { message: "Teléfono del responsable es requerido" }),
civilState: z.string().min(1, { message: "Estado civil es requerido" }),
familyBurden: z.coerce.number().min(0, { message: "Carga familiar requerida" }),
numberOfChildren: z.coerce.number().min(0, { message: "Número de hijos requerido" }),
ospResponsibleEmail: z.string().email({ message: "Correo electrónico inválido" }),
generalObservations: z.string().optional().default(''),
photo1: z.string().optional().default(''),
photo2: z.string().optional().default(''),
photo3: z.string().optional().default(''),
paralysisReason: z.string().optional().default(''),
state: z.number().optional().nullable(),
municipality: z.number().optional().nullable(),
parish: z.number().optional().nullable(),
});
export type TrainingSchema = z.infer<typeof trainingSchema>;
export const trainingApiResponseSchema = z.object({
message: z.string(),
data: z.array(trainingSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
totalCount: z.number(),
totalPages: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
nextPage: z.number().nullable(),
previousPage: z.number().nullable(),
}),
});
export const TrainingMutate = z.object({
message: z.string(),
data: trainingSchema,
});

View File

@@ -78,9 +78,7 @@ export function ModalForm({
parish: undefined
}
console.log(defaultValues);
// console.log(defaultValues);
const form = useForm<UpdateUser>({
resolver: zodResolver(updateUser),

View File

@@ -7,6 +7,8 @@
"add:api": "pnpm add --filter=api",
"add:web": "pnpm add --filter=web",
"build": "turbo build",
"build:api": "pnpm build --filter=api",
"build:web": "pnpm build --filter=web",
"changeset": "changeset",
"clear:modules": "npx npkill",
"commit": "cz",
@@ -18,9 +20,7 @@
"lint": "turbo lint",
"prepare": "husky",
"start": "turbo start",
"test": "turbo test",
"build:api": "pnpm build --filter=api",
"build:web": "pnpm build --filter=web"
"test": "turbo test"
},
"config": {
"commitizen": {