36 Commits

Author SHA1 Message Date
00ab65aee3 cambios en thorttler 2026-03-24 12:06:23 -04:00
524869b1f9 se agrego tea token. reemplaso del access token expirado. Para evitar refresh en cada consulta 2026-03-23 10:58:59 -04:00
f88ab2a971 Cambios en refresh token para no dar error. No actualiza access token de la cookie/session. Elimina access token de la cookie para forzar cerrar la session en caso de error 2026-03-23 10:20:48 -04:00
0666877811 correcciones tipado web para produccion 2026-03-06 13:23:53 -04:00
ff46776e4a corregido schema training quitado opcional del formulario varios campos 2026-03-06 10:50:19 -04:00
d6de7527e4 cambios en el formulario osp: rol autoridad, campos responsable cambiados, esquema cambiado, añadida columna fecha de creacion a la tabla 2026-03-04 15:07:31 -04:00
f910aea3cc nuevas correciones al formulario y esquema base de datos para osp 2026-02-25 12:17:33 -04:00
a88cf94adb cambio estilos y colores interfaz web 2026-02-24 11:43:45 -04:00
70e5200549 correcion de tipado en equipament_list.tsx 2026-02-24 11:14:04 -04:00
c70e146ce2 anexado guardar en minio y cambios generales en la interfaz de osp 2026-02-24 11:00:50 -04:00
fed90d9ff1 primeros cambios en el formulario osp 2026-02-23 21:15:29 -04:00
0efd5a11bd cambios en la interfaz de organizaciones 2026-02-23 12:40:30 -04:00
e149500735 Merge branch 'main' of https://git.fondemi.gob.ve/Fondemi/sistema_base
cambios en como se guardan los datos
2026-02-23 10:57:56 -04:00
d71ad98e85 ajustes al formulario de organizaciones 2026-02-23 10:57:45 -04:00
590f62fad9 Se guarda el id del usuario que creo el osp y el que lo actualice 2026-02-23 10:15:24 -04:00
510327de58 Cambio de estilo al tema claro/oscuro 2026-02-12 12:47:17 -04:00
a0c363dd1b corregido guardar registro osp 2026-02-11 11:30:22 -04:00
42e802f8a7 corregido refreshtoken y mejorado ver informacion ui por roles 2026-02-10 21:45:34 -04:00
63c39e399e corrreciones al formulario de las osp 2026-02-03 14:19:57 -04:00
26fb849fa3 Exportar OSP en excel con formato especifico (falta img y datos que no estan en el formulario) 2026-02-01 16:58:50 -04:00
2566e5e9a7 correciones visuales al formulario 2026-01-29 09:31:00 -04:00
8efe595f73 correciones al formulario osp 2026-01-28 22:42:19 -04:00
d2908f1e4c agregado los campos de distribucion (interna y externa) 2026-01-28 15:54:26 -04:00
69843e9e68 Algunos campos agregados/eliminados 2026-01-28 13:50:10 -04:00
5c080c6d32 datos de visita agregada 2026-01-26 14:57:23 -04:00
08a5567d60 mejoras al formulario de registro organizaciones productivas 2026-01-22 14:28:24 -04:00
69b3aab02a Se cambio en el modulo de editar producto el tipo de input a number 2025-12-15 13:12:10 -04:00
b8b11259cd Merge branch 'main' of ssh://git.fondemi.gob.ve:222/Fondemi/sistema_base 2025-12-15 13:06:33 -04:00
6482e692b3 Se cambio del modulo de producto-inventario el $ por Bs. 2025-12-15 13:04:00 -04:00
c1d1626e9e correcion routes web 2025-12-15 12:11:54 -04:00
824685723b correcion training 2025-12-15 11:58:41 -04:00
127e3b0e7a corregido erro codigo repetido auth 2025-12-15 11:20:33 -04:00
ee499abcf9 aceptado cambios auth 2025-12-15 10:53:36 -04:00
949d54e590 correciones de compilacion 2025-12-15 10:04:38 -04:00
28d51a9c00 correccion en un and con mala sintaxi en la sesiones 2025-10-09 12:01:13 -04:00
c1d4a40244 refresh token esta vez si (espero) 2025-10-09 11:25:46 -04:00
106 changed files with 40443 additions and 1930 deletions

View File

@@ -17,3 +17,10 @@ DATABASE_URL="postgresql://postgres:local**@localhost:5432/caja_ahorro" #url con
MAIL_HOST=gmail
MAIL_USERNAME=
MAIL_PASSWORD=
MINIO_ENDPOINT=
MINIO_PORT=
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_BUCKET=
MINIO_USE_SSL=

3
apps/api/.gitignore vendored
View File

@@ -54,3 +54,6 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Uploads
/uploads/training/*

View File

@@ -5,6 +5,13 @@
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
"typeCheck": true,
"assets": [
{
"include": "features/training/export_template/*.xlsx",
"outDir": "dist",
"watchAssets": true
}
]
}
}

View File

@@ -44,12 +44,15 @@
"drizzle-orm": "0.40.0",
"express": "5.1.0",
"joi": "17.13.3",
"minio": "^8.0.6",
"moment": "2.30.1",
"path-to-regexp": "8.2.0",
"pg": "8.13.3",
"pino-pretty": "13.0.0",
"reflect-metadata": "0.2.0",
"rxjs": "7.8.1"
"rxjs": "7.8.1",
"sharp": "^0.34.5",
"xlsx-populate": "^1.21.0"
},
"devDependencies": {
"@nestjs-modules/mailer": "^2.0.2",

View File

@@ -10,16 +10,17 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ThrottlerGuard } from '@nestjs/throttler';
import { MinioModule } from './common/minio/minio.module';
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 { InventoryModule } from './features/inventory/inventory.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 { TrainingModule } from './features/training/training.module';
import { UserRolesModule } from './features/user-roles/user-roles.module';
@Module({
providers: [
@@ -51,6 +52,7 @@ import { TrainingModule } from './features/training/training.module';
NodeMailerModule,
LoggerModule,
ThrottleModule,
MinioModule,
UsersModule,
AuthModule,
MailModule,
@@ -61,7 +63,7 @@ import { TrainingModule } from './features/training/training.module';
SurveysModule,
LocationModule,
InventoryModule,
TrainingModule
TrainingModule,
],
})
export class AppModule { }
export class AppModule {}

View File

@@ -14,6 +14,12 @@ interface EnvVars {
MAIL_HOST: string;
MAIL_USERNAME: string;
MAIL_PASSWORD: string;
MINIO_ENDPOINT: string;
MINIO_PORT: number;
MINIO_ACCESS_KEY: string;
MINIO_SECRET_KEY: string;
MINIO_BUCKET: string;
MINIO_USE_SSL: boolean;
}
const envsSchema = joi
@@ -30,6 +36,12 @@ const envsSchema = joi
MAIL_HOST: joi.string(),
MAIL_USERNAME: joi.string(),
MAIL_PASSWORD: joi.string(),
MINIO_ENDPOINT: joi.string().required(),
MINIO_PORT: joi.number().required(),
MINIO_ACCESS_KEY: joi.string().required(),
MINIO_SECRET_KEY: joi.string().required(),
MINIO_BUCKET: joi.string().required(),
MINIO_USE_SSL: joi.boolean().default(false),
})
.unknown(true);
@@ -54,4 +66,10 @@ export const envs = {
mail_host: envVars.MAIL_HOST,
mail_username: envVars.MAIL_USERNAME,
mail_password: envVars.MAIL_PASSWORD,
minio_endpoint: envVars.MINIO_ENDPOINT,
minio_port: envVars.MINIO_PORT,
minio_access_key: envVars.MINIO_ACCESS_KEY,
minio_secret_key: envVars.MINIO_SECRET_KEY,
minio_bucket: envVars.MINIO_BUCKET,
minio_use_ssl: envVars.MINIO_USE_SSL,
};

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { MinioService } from './minio.service';
@Global()
@Module({
providers: [MinioService],
exports: [MinioService],
})
export class MinioModule {}

View File

@@ -0,0 +1,127 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as Minio from 'minio';
import { envs } from '../config/envs';
@Injectable()
export class MinioService implements OnModuleInit {
private readonly minioClient: Minio.Client;
private readonly logger = new Logger(MinioService.name);
private readonly bucketName = envs.minio_bucket;
constructor() {
this.minioClient = new Minio.Client({
endPoint: envs.minio_endpoint,
port: envs.minio_port,
useSSL: envs.minio_use_ssl,
accessKey: envs.minio_access_key,
secretKey: envs.minio_secret_key,
});
}
async onModuleInit() {
await this.ensureBucketExists();
}
private async ensureBucketExists() {
// Ejecuta esto siempre al menos una vez para asegurar que sea público
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
},
],
};
try {
// const bucketExists = await this.minioClient.bucketExists(this.bucketName);
// if (!bucketExists) {
// await this.minioClient.makeBucket(this.bucketName);
// }
await this.minioClient.setBucketPolicy(
this.bucketName,
JSON.stringify(policy),
);
this.logger.log(`Public policy ensured for bucket "${this.bucketName}"`);
} catch (error: any) {
this.logger.error(`Error checking/creating bucket: ${error.message}`);
}
}
async upload(
file: Express.Multer.File,
folder: string = 'general',
): Promise<string> {
const fileName = `${Date.now()}-${Math.round(Math.random() * 1e9)}-${file.originalname.replace(/\s/g, '_')}`;
const objectName = `${folder}/${fileName}`;
try {
await this.minioClient.putObject(
this.bucketName,
objectName,
file.buffer,
file.size,
{
'Content-Type': file.mimetype,
},
);
// Return the URL or the object path.
// Usually, we store the object path and generate a signed URL or use a proxy.
// The user asked for the URL to be stored in the database.
return objectName;
} catch (error: any) {
this.logger.error(`Error uploading file: ${error.message}`);
throw error;
}
}
async getFileUrl(objectName: string): Promise<string> {
try {
// If the bucket is public, we can just return the URL.
// If private, we need a signed URL.
// For simplicity and common use cases in these projects, I'll generate a signed URL with a long expiration
// or assume there is some way to access it.
// But let's use signed URL for 1 week (maximum is 7 days) if needed,
// or just return the object name if the backend handles the serving.
// The user wants the URL stored in the DB.
return await this.minioClient.presignedUrl(
'GET',
this.bucketName,
objectName,
604800,
);
} catch (error: any) {
this.logger.error(`Error getting file URL: ${error.message}`);
throw error;
}
}
getPublicUrl(objectName: string): string {
const protocol = envs.minio_use_ssl ? 'https' : 'http';
return `${protocol}://${envs.minio_endpoint}:${envs.minio_port}/${this.bucketName}/${objectName}`;
}
async delete(objectName: string): Promise<void> {
try {
// Ensure we don't have a leading slash which can cause issues with removeObject
const cleanedName = objectName.startsWith('/')
? objectName.slice(1)
: objectName;
await this.minioClient.removeObject(this.bucketName, cleanedName);
this.logger.log(
`Object "${cleanedName}" deleted successfully from bucket "${this.bucketName}".`,
);
} catch (error: any) {
this.logger.error(
`Error deleting file "${objectName}": ${error.message}`,
);
// We don't necessarily want to throw if the file is already gone
}
}
}

View File

@@ -8,17 +8,17 @@ import { ThrottlerModule } from '@nestjs/throttler';
{
name: 'short',
ttl: 1000, // 1 sec
limit: 2,
limit: 10,
},
{
name: 'medium',
ttl: 10000, // 10 sec
limit: 4,
limit: 30,
},
{
name: 'long',
ttl: 60000, // 1 min
limit: 10,
limit: 100,
},
],
errorMessage: 'Too many requests, please try again later.',

View File

@@ -0,0 +1,36 @@
import { Injectable, PipeTransform } from '@nestjs/common';
import * as path from 'path';
import sharp from 'sharp';
@Injectable()
export class ImageProcessingPipe implements PipeTransform {
async transform(
files: Express.Multer.File[] | Express.Multer.File,
): Promise<Express.Multer.File[] | Express.Multer.File> {
if (!files) return files;
const processItem = async (
file: Express.Multer.File,
): Promise<Express.Multer.File> => {
const processedBuffer = await sharp(file.buffer)
.webp({ quality: 80 })
.toBuffer();
const originalName = path.parse(file.originalname).name;
return {
...file,
buffer: processedBuffer,
originalname: `${originalName}.webp`,
mimetype: 'image/webp',
size: processedBuffer.length,
};
};
if (Array.isArray(files)) {
return await Promise.all(files.map((file) => processItem(file)));
}
return await processItem(files);
}
}

View File

@@ -0,0 +1,4 @@
ALTER TABLE "training_surveys" ALTER COLUMN "current_status" SET DEFAULT 'ACTIVA';--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "photo2" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "photo3" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "product_count" integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,14 @@
ALTER TABLE "training_surveys" ADD COLUMN "commune_name" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "commune_rif" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "commune_spokesperson_name" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "commune_spokesperson_cedula" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "commune_spokesperson_rif" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "commune_spokesperson_phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "commune_email" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "communal_council_rif" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "communal_council_spokesperson_name" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "communal_council_spokesperson_cedula" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "communal_council_spokesperson_rif" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "communal_council_spokesperson_phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "communal_council_email" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "osp_google_maps_link" text DEFAULT '' NOT NULL;

View File

@@ -0,0 +1,20 @@
ALTER TABLE "training_surveys" ADD COLUMN "coor_state" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "coor_municipality" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "coor_parish" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "coor_phone" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "eco_sector" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "productive_sector" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "central_productive_activity" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "main_productive_activity" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "types_of_equipment" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "equipment_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "equipment_description" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "raw_material" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "material_type" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "raw_material_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "product_count_daily" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "product_count_weekly" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "product_count_monthly" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_coor_state_states_id_fk" FOREIGN KEY ("coor_state") REFERENCES "public"."states"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_coor_municipality_municipalities_id_fk" FOREIGN KEY ("coor_municipality") REFERENCES "public"."municipalities"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_coor_parish_parishes_id_fk" FOREIGN KEY ("coor_parish") REFERENCES "public"."parishes"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,9 @@
ALTER TABLE "training_surveys" ALTER COLUMN "financial_requirement_description" SET DEFAULT '';--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "prod_description_internal" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "internal_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "prod_description_external" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "country" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "city" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "men_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "women_count" integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,9 @@
ALTER TABLE "training_surveys" ADD COLUMN "infrastructure_mt2" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "has_transport" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "structure_type" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "is_open_space" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "equipment_list" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "production_list" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "product_list" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "internal_distribution_list" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_distribution_list" jsonb DEFAULT '[]'::jsonb NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "training_surveys" ALTER COLUMN "photo1" DROP NOT NULL;

View File

@@ -0,0 +1,36 @@
ALTER TABLE "training_surveys" DROP CONSTRAINT "training_surveys_coor_state_states_id_fk";
--> statement-breakpoint
ALTER TABLE "training_surveys" DROP CONSTRAINT "training_surveys_coor_municipality_municipalities_id_fk";
--> statement-breakpoint
ALTER TABLE "training_surveys" DROP CONSTRAINT "training_surveys_coor_parish_parishes_id_fk";
--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "general_observations" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "paralysis_reason" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "financial_requirement_description";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "producer_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "product_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "product_description";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "installed_capacity";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "operational_capacity";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "coor_state";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "coor_municipality";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "coor_parish";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "types_of_equipment";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "equipment_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "equipment_description";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "raw_material";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "material_type";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "raw_material_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "product_count_daily";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "product_count_weekly";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "product_count_monthly";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "prod_description_internal";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "internal_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "external_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "prod_description_external";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "country";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "city";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "men_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "women_count";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "internal_distribution_list";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "external_distribution_list";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth"."sessions" ADD COLUMN "previous_session_token" varchar;--> statement-breakpoint
ALTER TABLE "auth"."sessions" ADD COLUMN "last_rotated_at" timestamp;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "training_surveys" ADD COLUMN "created_by" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "updated_by" integer;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD CONSTRAINT "training_surveys_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,10 @@
ALTER TABLE "training_surveys" ALTER COLUMN "commune_spokesperson_cedula" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "commune_spokesperson_cedula" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "commune_spokesperson_rif" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "commune_spokesperson_rif" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "commune_email" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "commune_email" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "communal_council_spokesperson_cedula" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "communal_council_spokesperson_cedula" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "communal_council_spokesperson_rif" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "communal_council_spokesperson_rif" DROP NOT NULL;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "training_surveys" ALTER COLUMN "osp_responsible_rif" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "civil_state" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "osp_responsible_email" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "family_burden" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "number_of_children" DROP NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "training_surveys" ALTER COLUMN "osp_rif" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ALTER COLUMN "osp_name" DROP NOT NULL;

View File

@@ -0,0 +1,9 @@
ALTER TABLE "training_surveys" ADD COLUMN "internal_distribution_zone" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "is_exporting" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_country" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_city" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_description" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_quantity" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "external_unit" text;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "women_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "men_count" integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,5 @@
DROP INDEX "training_surveys_index_00";--> statement-breakpoint
ALTER TABLE "training_surveys" ADD COLUMN "coor_full_name" text NOT NULL;--> statement-breakpoint
CREATE INDEX "training_surveys_index_00" ON "training_surveys" USING btree ("coor_full_name");--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "firstname";--> statement-breakpoint
ALTER TABLE "training_surveys" DROP COLUMN "lastname";

View File

@@ -0,0 +1 @@
CREATE VIEW "public"."v_training_surveys" AS (select "id", "osp_name", "osp_rif", "osp_type", "current_status", "visit_date" from "training_surveys");

View File

@@ -0,0 +1,2 @@
DROP VIEW "public"."v_training_surveys";--> statement-breakpoint
CREATE VIEW "public"."v_training_surveys" AS (select "id", "coor_full_name", "visit_date", "coor_phone", "state", "municipality", "parish", "osp_type", "eco_sector", "productive_sector", "central_productive_activity", "main_productive_activity", "productive_activity", "osp_rif", "osp_name", "company_constitution_year", "current_status", "infrastructure_mt2", "has_transport", "structure_type", "is_open_space", "paralysis_reason", "equipment_list", "production_list", "product_list", "osp_address", "osp_google_maps_link", "commune_name", "situr_code_commune", "commune_rif", "commune_spokesperson_name", "commune_spokesperson_cedula", "commune_spokesperson_rif", "commune_spokesperson_phone", "commune_email", "communal_council", "situr_code_communal_council", "communal_council_rif", "communal_council_spokesperson_name", "communal_council_spokesperson_cedula", "communal_council_spokesperson_rif", "communal_council_spokesperson_phone", "communal_council_email", "osp_responsible_fullname", "osp_responsible_cedula", "osp_responsible_rif", "civil_state", "osp_responsible_phone", "osp_responsible_email", "family_burden", "number_of_children", "general_observations", "internal_distribution_zone", "is_exporting", "external_country", "external_city", "external_description", "external_quantity", "external_unit", "women_count", "men_count", "photo1", "photo2", "photo3", "created_by", "updated_by", "created_at", "updated_at" from "training_surveys");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,118 @@
"when": 1764883378610,
"tag": "0009_eminent_ares",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769097895095,
"tag": "0010_dashing_bishop",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769618795008,
"tag": "0011_magical_thundra",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769621656400,
"tag": "0012_sudden_venus",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769629815868,
"tag": "0013_cuddly_night_nurse",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769646908602,
"tag": "0014_deep_meteorite",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1769648728698,
"tag": "0015_concerned_wild_pack",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1769653021994,
"tag": "0016_silent_tag",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1770774052351,
"tag": "0017_mute_mole_man",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1771855467870,
"tag": "0018_milky_prism",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1771858973096,
"tag": "0019_cuddly_cobalt_man",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1771897944334,
"tag": "0020_certain_bushwacker",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1771901546945,
"tag": "0021_warm_machine_man",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1772031518006,
"tag": "0022_nervous_dragon_lord",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1772032122473,
"tag": "0023_sticky_slayback",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1772642460042,
"tag": "0024_petite_sabra",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1772643066120,
"tag": "0025_funny_makkari",
"breakpoints": true
}
]
}

View File

@@ -1,9 +1,8 @@
import * as t from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { authSchema } from './schemas';
import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../timestamps';
import { states, municipalities, parishes } from './general';
import { municipalities, parishes, states } from './general';
import { authSchema } from './schemas';
// Tabla de Usuarios sistema
export const users = authSchema.table(
@@ -15,9 +14,15 @@ export const users = authSchema.table(
fullname: t.text('fullname').notNull(),
phone: t.text('phone'),
password: t.text('password').notNull(),
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' }),
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' }),
isTwoFactorEnabled: t
.boolean('is_two_factor_enabled')
.notNull()
@@ -32,7 +37,6 @@ export const users = authSchema.table(
}),
);
// Tabla de Roles
export const roles = authSchema.table(
'roles',
@@ -46,8 +50,6 @@ export const roles = authSchema.table(
}),
);
//tabla User_roles
export const usersRole = authSchema.table(
'user_role',
@@ -88,7 +90,6 @@ LEFT JOIN
LEFT JOIN
auth.roles r ON ur.role_id = r.id`);
// Tabla de Sesiones
export const sessions = authSchema.table(
'sessions',
@@ -103,6 +104,9 @@ export const sessions = authSchema.table(
.notNull(),
sessionToken: t.text('session_token').notNull(),
expiresAt: t.integer('expires_at').notNull(),
previousSessionToken: t.varchar('previous_session_token'),
lastRotatedAt: t.timestamp('last_rotated_at'),
...timestamps,
},
(sessions) => ({
@@ -110,8 +114,6 @@ export const sessions = authSchema.table(
}),
);
//tabla de tokens de verificación
export const verificationTokens = authSchema.table(
'verificationToken',

View File

@@ -1,9 +1,8 @@
import { sql } from 'drizzle-orm';
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';
import { municipalities, parishes, states } from './general';
// Tabla surveys
export const surveys = t.pgTable(
@@ -19,9 +18,7 @@ export const surveys = t.pgTable(
...timestamps,
},
(surveys) => ({
surveysIndex: t
.index('surveys_index_00')
.on(surveys.title),
surveysIndex: t.index('surveys_index_00').on(surveys.title),
}),
);
@@ -49,52 +46,124 @@ export const answersSurveys = t.pgTable(
export const trainingSurveys = t.pgTable(
'training_surveys',
{
// Datos basicos
// === 1. IDENTIFICADORES Y DATOS DE VISITA ===
id: t.serial('id').primaryKey(),
firstname: t.text('firstname').notNull(),
lastname: t.text('lastname').notNull(),
coorFullName: t.text('coor_full_name').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' }),
coorPhone: t.text('coor_phone'),
// === 2. UBICACIÓN (Claves Foráneas - Nullables) ===
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' }),
// === 3. DATOS DE LA OSP (Organización Socioproductiva) ===
ospType: t.text('osp_type').notNull(), // UPF, EPS, etc.
ecoSector: t.text('eco_sector').notNull().default(''),
productiveSector: t.text('productive_sector').notNull().default(''),
centralProductiveActivity: t
.text('central_productive_activity')
.notNull()
.default(''),
mainProductiveActivity: t
.text('main_productive_activity')
.notNull()
.default(''),
productiveActivity: t.text('productive_activity').notNull(),
ospRif: t.text('osp_rif'),
ospName: t.text('osp_name'),
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
currentStatus: t.text('current_status').notNull().default('ACTIVA'),
infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''),
hasTransport: t.boolean('has_transport').notNull().default(false),
structureType: t.text('structure_type').notNull().default(''),
isOpenSpace: t.boolean('is_open_space').notNull().default(false),
paralysisReason: t.text('paralysis_reason'),
equipmentList: t.jsonb('equipment_list').notNull().default([]),
productionList: t.jsonb('production_list').notNull().default([]),
productList: t.jsonb('product_list').notNull().default([]),
ospAddress: t.text('osp_address').notNull(),
ospGoogleMapsLink: t.text('osp_google_maps_link').notNull().default(''),
communeName: t.text('commune_name').notNull().default(''),
siturCodeCommune: t.text('situr_code_commune').notNull(),
communeRif: t.text('commune_rif').notNull().default(''),
communeSpokespersonName: t
.text('commune_spokesperson_name')
.notNull()
.default(''),
communeSpokespersonCedula: t.text('commune_spokesperson_cedula'),
communeSpokespersonRif: t.text('commune_spokesperson_rif'),
communeSpokespersonPhone: t
.text('commune_spokesperson_phone')
.notNull()
.default(''),
communeEmail: t.text('commune_email'),
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
communalCouncilRif: t.text('communal_council_rif').notNull().default(''),
communalCouncilSpokespersonName: t
.text('communal_council_spokesperson_name')
.notNull()
.default(''),
communalCouncilSpokespersonCedula: t.text(
'communal_council_spokesperson_cedula',
),
communalCouncilSpokespersonRif: t.text('communal_council_spokesperson_rif'),
communalCouncilSpokespersonPhone: t
.text('communal_council_spokesperson_phone')
.notNull()
.default(''),
communalCouncilEmail: t
.text('communal_council_email')
.notNull()
.default(''),
ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(),
ospResponsibleCedula: t.text('osp_responsible_cedula').notNull(),
ospResponsibleRif: t.text('osp_responsible_rif').notNull(),
ospResponsibleRif: t.text('osp_responsible_rif'),
civilState: t.text('civil_state'),
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(),
ospResponsibleEmail: t.text('osp_responsible_email'),
familyBurden: t.integer('family_burden'),
numberOfChildren: t.integer('number_of_children'),
generalObservations: t.text('general_observations'),
// === 4. DATOS DE DISTRIBUCIÓN Y EXPORTACIÓN ===
internalDistributionZone: t.text('internal_distribution_zone'),
isExporting: t.boolean('is_exporting').notNull().default(false),
externalCountry: t.text('external_country'),
externalCity: t.text('external_city'),
externalDescription: t.text('external_description'),
externalQuantity: t.text('external_quantity'),
externalUnit: t.text('external_unit'),
// === 5. MANO DE OBRA ===
womenCount: t.integer('women_count').notNull().default(0),
menCount: t.integer('men_count').notNull().default(0),
// Fotos
photo1: t.text('photo1'),
photo2: t.text('photo2'),
photo3: t.text('photo3'),
// informacion del usuario que creo y actualizo el registro
createdBy: t
.integer('created_by')
.references(() => users.id, { onDelete: 'cascade' }),
updatedBy: t
.integer('updated_by')
.references(() => users.id, { onDelete: 'cascade' }),
...timestamps,
},
(trainingSurveys) => ({
trainingSurveysIndex: t.index('training_surveys_index_00').on(trainingSurveys.firstname),
})
trainingSurveysIndex: t
.index('training_surveys_index_00')
.on(trainingSurveys.coorFullName),
}),
);
export const viewSurveys = t.pgView('v_surveys', {
@@ -103,6 +172,7 @@ export const viewSurveys = t.pgView('v_surveys', {
description: t.text('description'),
created_at: t.timestamp('created_at'),
closingDate: t.date('closing_date'),
targetAudience: t.varchar('target_audience')
}).as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
targetAudience: t.varchar('target_audience'),
})
.as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);

View File

@@ -1,25 +1,14 @@
// api/src/feacture/auth/auth.controller.ts
import { Public } from '@/common/decorators';
import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import {
Body,
Controller,
Get,
HttpCode,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Body, Controller, HttpCode, Patch, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(private readonly authService: AuthService) { }
@Public()
@HttpCode(200)
@@ -39,6 +28,8 @@ export class AuthController {
return await this.authService.signIn(signInUserDto);
}
@Public()
@HttpCode(200)
@Post('sign-out')
//@RequirePermissions('auth:sign-out')
async signOut(@Body() signOutUserDto: SignOutUserDto) {
@@ -58,12 +49,11 @@ export class AuthController {
@Patch('refresh')
//@RequirePermissions('auth:refresh-token')
async refreshToken(@Body() refreshTokenDto: any) {
// console.log('REFRESCANDO');
// console.log(refreshTokenDto);
// console.log('-----------');
const data = await this.authService.refreshToken(refreshTokenDto);
if (!data) return null;
return {tokens: data}
return await this.authService.refreshToken(refreshTokenDto);
}
// @Public()

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 { 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 as any,
},
),
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.refresh_token_secret,
expiresIn: envs.refresh_token_expiration as any,
},
),
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);
@@ -263,75 +273,151 @@ export class AuthService {
//Refresh User Access Token
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
const { user_id, refresh_token } = dto;
// const user_id = 1;
const { refreshToken } = dto;
const validation = await this.jwtService.verifyAsync(refresh_token, {
// 1. Validar firma del token (Crypto check)
let payload: any;
try {
payload = await this.jwtService.verifyAsync(refreshToken, {
secret: envs.refresh_token_secret,
});
} catch (e) {
throw new UnauthorizedException('Invalid Refresh Token Signature');
}
if (!validation) throw new UnauthorizedException('Invalid refresh token');
const userId = Number(payload.sub); // Es más seguro usar el ID del token que el del DTO
const session = await this.drizzle
// 2. Buscar la sesión por UserID (SIN filtrar por token todavía)
// Esto es clave: traemos la sesión para ver qué está pasando
const [currentSession] = await this.drizzle
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, user_id) &&
eq(sessions.sessionToken, dto.refresh_token),
),
);
.where(eq(sessions.userId, userId));
// console.log(session.length);
if (!currentSession) throw new NotFoundException('Session not found');
if (session.length === 0) throw new NotFoundException('session not found');
const user = await this.findUserById(user_id);
// CONFIGURACIÓN: Tiempo de gracia en milisegundos (ej: 15 segundos)
const GRACE_PERIOD_MS = 15000;
// -------------------------------------------------------------------
// ESCENARIO A: Rotación Normal (El token coincide con el actual)
// -------------------------------------------------------------------
if (currentSession.sessionToken === refreshToken) {
const user = await this.findUserById(userId);
if (!user) throw new NotFoundException('User not found');
// Genera token
const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token);
const decodeRefresh = this.decodeToken(tokens.refresh_token);
// Generar nuevos tokens (A -> B)
const tokensNew = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokensNew.access_token);
const decodeRefresh = this.decodeToken(tokensNew.refresh_token);
// Actualiza session
// Actualizamos DB guardando el token "viejo" como "previous"
await this.drizzle
.update(sessions)
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
.where(eq(sessions.userId, user_id));
.set({
sessionToken: tokensNew.refresh_token, // Nuevo (B)
previousSessionToken: refreshToken, // Viejo (A)
lastRotatedAt: new Date(), // Marca de tiempo
expiresAt: decodeRefresh.exp,
})
.where(eq(sessions.userId, userId));
return {
access_token: tokens.access_token,
access_token: tokensNew.access_token,
access_expire_in: decodeAccess.exp,
refresh_token: tokens.refresh_token,
refresh_token: tokensNew.refresh_token,
refresh_expire_in: decodeRefresh.exp,
};
}
// -------------------------------------------------------------------
// ESCENARIO B: Periodo de Gracia (Condición de Carrera)
// -------------------------------------------------------------------
// El token no coincide con el actual, ¿pero coincide con el anterior?
const isPreviousToken =
currentSession.previousSessionToken === refreshToken;
// Calculamos cuánto tiempo ha pasado desde la rotación
const timeSinceRotation = currentSession.lastRotatedAt
? Date.now() - new Date(currentSession.lastRotatedAt).getTime()
: Infinity;
if (isPreviousToken && timeSinceRotation < GRACE_PERIOD_MS) {
// ¡Es una condición de carrera! El usuario envió 'A' pero ya rotamos a 'B'.
// Le devolvemos 'B' (el actual en DB) para que se sincronice.
const user = await this.findUserById(userId);
if (!user) throw new NotFoundException('User not found');
// Generamos un access token nuevo fresco (barato)
const accessTokenPayload = { sub: user.id, username: user.username };
const newAccessToken = await this.jwtService.signAsync(
accessTokenPayload,
{
secret: envs.access_token_secret,
expiresIn: envs.access_token_expiration,
} as JwtSignOptions,
);
const decodeAccess = this.decodeToken(newAccessToken);
// IMPORTANTE: Devolvemos el refresh token QUE YA ESTÁ EN LA BASE DE DATOS
// No generamos uno nuevo para no romper la cadena de la otra petición que ganó.
return {
access_token: newAccessToken,
access_expire_in: decodeAccess.exp,
refresh_token: currentSession.sessionToken!, // Devolvemos el token 'B'
refresh_expire_in: currentSession.expiresAt as number,
};
}
// -------------------------------------------------------------------
// ESCENARIO C: Robo de Token (Reuse Detection)
// -------------------------------------------------------------------
// Si el token no es el actual, ni el anterior válido... ALGUIEN LO ROBÓ.
// O el usuario está intentando reusar un token muy viejo.
// Medida de seguridad: Borrar todas las sesiones del usuario
await this.drizzle.delete(sessions).where(eq(sessions.userId, userId));
throw new UnauthorizedException(
'Refresh token reuse detected. Access revoked.',
);
}
async singUp(createUserDto: SingUpUserDto): Promise<User> {
// Check if username or email exists
const data = await this.drizzle
.select({
id: users.id,
username: users.username,
email: users.email
email: users.email,
})
.from(users)
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email)));
.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);
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) => {
// Hash the password
// Create the user
const [newUser] = await tx
.insert(users)
@@ -352,6 +438,7 @@ export class AuthService {
// 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({
@@ -376,7 +463,6 @@ export class AuthService {
.where(eq(users.id, newUser.id));
return userWithRole;
})
});
}
}

View File

@@ -7,9 +7,9 @@ export class RefreshTokenDto {
@IsString({
message: 'Refresh token must be a string',
})
refresh_token: string;
refreshToken: string;
@ApiProperty()
@IsNumber()
user_id: number;
userId: number;
}

View File

@@ -1,18 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, IsDateString, IsOptional } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsEmail,
IsInt,
IsOptional,
IsString,
ValidateIf,
} from 'class-validator';
export class CreateTrainingDto {
// === 1. DATOS BÁSICOS ===
@ApiProperty()
@IsString()
firstname: string;
@ApiProperty()
@IsString()
lastname: string;
coorFullName: string;
@ApiProperty()
@IsDateString()
visitDate: string;
visitDate: string; // Llega como string ISO "2024-11-11T10:00"
@ApiProperty()
@IsString()
@IsOptional()
coorPhone?: string;
// === 2. DATOS OSP ===
@ApiProperty()
@IsOptional()
ospName: string;
@ApiProperty()
@IsOptional()
ospRif: string;
@ApiProperty()
@IsString()
ospType: string; // 'UPF', etc.
@ApiProperty()
@IsString()
@@ -20,24 +44,138 @@ export class CreateTrainingDto {
@ApiProperty()
@IsString()
financialRequirementDescription: string;
currentStatus: string;
@ApiProperty()
@IsInt()
state: number;
@ApiProperty()
@IsInt()
municipality: number;
@ApiProperty()
@IsInt()
parish: number;
@Type(() => Number) // Convierte "2017" -> 2017
companyConstitutionYear: number;
@ApiProperty()
@IsString()
@IsOptional()
ospAddress: string;
@ApiProperty()
@IsString()
@IsOptional()
ospGoogleMapsLink?: string;
@ApiProperty()
@IsString()
@IsOptional()
infrastructureMt2?: string;
@ApiProperty()
@IsString()
@IsOptional()
structureType?: string;
@ApiProperty()
@IsString()
@IsOptional()
hasTransport?: string;
@ApiProperty()
@IsString()
@IsOptional()
isOpenSpace?: string;
@ApiProperty()
@IsString()
@IsOptional()
paralysisReason?: string;
@ApiProperty()
@IsString()
@IsOptional()
generalObservations?: string;
// === 3. SECTORES ===
@ApiProperty()
@IsString()
ecoSector: string;
@ApiProperty()
@IsString()
productiveSector: string;
@ApiProperty()
@IsString()
centralProductiveActivity: string;
@ApiProperty()
@IsString()
mainProductiveActivity: string;
// === 4. DATOS RESPONSABLE ===
@ApiProperty()
@IsString()
ospResponsibleFullname: string;
@ApiProperty()
@IsString()
ospResponsibleCedula: string;
@ApiProperty()
@IsString()
@IsOptional()
ospResponsibleRif: string;
@ApiProperty()
@IsString()
ospResponsiblePhone: string;
@ApiProperty()
@IsOptional()
@ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
ospResponsibleEmail?: string;
@ApiProperty()
@IsString()
@IsOptional()
civilState: string;
@ApiProperty()
@IsInt()
@IsOptional()
@Type(() => Number) // Convierte "3" -> 3
familyBurden: number;
@ApiProperty()
@IsInt()
@IsOptional()
@Type(() => Number)
numberOfChildren: number;
// === 5. COMUNA Y CONSEJO COMUNAL ===
@ApiProperty()
@IsString()
siturCodeCommune: string;
@ApiProperty()
@IsString()
communeName: string;
@ApiProperty()
@IsString()
communeRif: string;
@ApiProperty()
@IsString()
communeSpokespersonName: string;
@ApiProperty()
@IsString()
communeSpokespersonPhone: string;
@ApiProperty()
@IsOptional()
@ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
communeEmail?: string;
@ApiProperty()
@IsString()
communalCouncil: string;
@@ -48,93 +186,144 @@ export class CreateTrainingDto {
@ApiProperty()
@IsString()
ospName: string;
communalCouncilRif: string;
@ApiProperty()
@IsString()
ospAddress: string;
communalCouncilSpokespersonName: string;
@ApiProperty()
@IsString()
ospRif: string;
communalCouncilSpokespersonPhone: string;
@ApiProperty()
@IsOptional()
@ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
communalCouncilEmail?: string;
// === 6. DISTRIBUCIÓN Y EXPORTACIÓN ===
@ApiProperty()
@IsString()
@IsOptional()
internalDistributionZone?: string;
@ApiProperty()
@IsString()
ospType: string;
@IsOptional()
isExporting?: string;
@ApiProperty()
@IsString()
currentStatus: string;
@IsOptional()
externalCountry?: string;
@ApiProperty()
@IsString()
@IsOptional()
externalCity?: string;
@ApiProperty()
@IsString()
@IsOptional()
externalDescription?: string;
@ApiProperty()
@IsString()
@IsOptional()
externalQuantity?: string;
@ApiProperty()
@IsString()
@IsOptional()
externalUnit?: string;
// === 7. MANO DE OBRA ===
@ApiProperty()
@IsInt()
@IsOptional()
@Type(() => Number)
womenCount?: number;
@ApiProperty()
@IsInt()
companyConstitutionYear: number;
@IsOptional()
@Type(() => Number)
menCount?: number;
// === 8. LISTAS (Arrays JSON) ===
// Reciben un string JSON '[{...}]' y lo convierten a Objeto JS real
@ApiProperty()
@IsInt()
producerCount: number;
@IsOptional()
@IsArray()
@Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return [];
}
}
return value;
})
equipmentList?: any[];
@ApiProperty()
@IsOptional()
@IsArray()
@Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return [];
}
}
return value;
})
productionList?: any[];
@ApiProperty()
@IsOptional()
@IsArray()
@Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return [];
}
}
return value;
})
productList?: any[];
//ubicacion
@ApiProperty()
@IsString()
state: string;
@ApiProperty()
@IsString()
productDescription: string;
municipality: string;
@ApiProperty()
@IsString()
installedCapacity: string;
parish: string;
@ApiProperty()
@IsString()
operationalCapacity: string;
@IsOptional()
photo1?: string;
@ApiProperty()
@IsString()
ospResponsibleFullname: string;
@IsOptional()
photo2?: 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;
@IsOptional()
photo3?: string;
}

View File

@@ -1,26 +1,67 @@
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 {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { ImageProcessingPipe } from '../../common/pipes/image-processing.pipe';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingService } from './training.service';
@ApiTags('training')
@Controller('training')
export class TrainingController {
constructor(private readonly trainingService: TrainingService) { }
constructor(private readonly trainingService: TrainingService) {}
// @Public()
// @Get('export/:id')
// @ApiOperation({ summary: 'Export training template' })
// @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 exportTemplate(@Param('id') id: string) {
// if (!Number(id)) {
// throw new Error('ID is required');
// }
// const data = await this.trainingService.exportTemplate(Number(id));
// return new StreamableFile(data);
// }
@Get()
@ApiOperation({ summary: 'Get all training records with pagination and filters' })
@ApiResponse({ status: 200, description: 'Return paginated training records.' })
@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
meta: result.meta,
};
}
@@ -42,25 +83,58 @@ export class TrainingController {
}
@Post()
@UseInterceptors(FilesInterceptor('files', 3))
@ApiConsumes('multipart/form-data')
@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);
@ApiResponse({
status: 201,
description: 'Training record created successfully.',
})
async create(
@Req() req: Request,
@Body() createTrainingDto: CreateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) {
const userId = (req as any).user?.id;
const data = await this.trainingService.create(
createTrainingDto,
files,
userId,
);
return { message: 'Training record created successfully', data };
}
@Patch(':id')
@UseInterceptors(FilesInterceptor('files', 3))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Update a training record' })
@ApiResponse({ status: 200, description: 'Training record updated successfully.' })
@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);
async update(
@Req() req: Request,
@Param('id') id: string,
@Body() updateTrainingDto: UpdateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) {
const userId = (req as any).user?.id;
const data = await this.trainingService.update(
+id,
updateTrainingDto,
files,
userId,
);
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: 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

@@ -1,38 +1,41 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { MinioService } from '@/common/minio/minio.service';
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import * as schema from 'src/database/index';
import { 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 { states, trainingSurveys } from 'src/database/index';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
@Injectable()
export class TrainingService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { }
private readonly minioService: MinioService,
) {}
async findAll(paginationDto?: PaginationDto) {
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
const {
page = 1,
limit = 10,
search = '',
sortBy = 'id',
sortOrder = 'asc',
} = paginationDto || {};
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}%`)
);
searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
}
const orderBy = sortOrder === 'asc'
const orderBy =
sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
@@ -67,104 +70,136 @@ export class TrainingService {
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto;
const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
filterDto;
const filters: SQL[] = [];
if (startDate) {
if (startDate)
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
}
if (endDate) {
if (endDate)
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
}
if (stateId) {
filters.push(eq(trainingSurveys.state, stateId));
}
if (municipalityId) {
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));
}
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
// Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad
const [
totalOspsResult,
totalProducersResult,
totalProductsResult, // Nuevo: Calculado desde el JSON
statusDistribution,
activityDistribution,
typeDistribution,
stateDistribution,
yearDistribution,
] = await Promise.all([
// 1. Total OSPs
this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition);
const totalOsps = Number(totalOspsResult[0].count);
.where(whereCondition),
const totalProducersResult = await this.drizzle
.select({ sum: sql<number>`sum(${trainingSurveys.producerCount})` })
// 2. Total Productores (Columna plana que mantuviste)
this.drizzle
.select({
sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
})
.from(trainingSurveys)
.where(whereCondition);
const totalProducers = Number(totalProducersResult[0].sum || 0);
.where(whereCondition),
const statusDistribution = await this.drizzle
// 3. NUEVO: Total Productos (Contamos el largo del array JSON productList)
this.drizzle
.select({
sum: sql<number>`sum(jsonb_array_length(${trainingSurveys.productList}))`,
})
.from(trainingSurveys)
.where(whereCondition),
// 4. Distribución por Estatus
this.drizzle
.select({
name: trainingSurveys.currentStatus,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus);
.groupBy(trainingSurveys.currentStatus),
const activityDistribution = await this.drizzle
// 5. Distribución por Actividad
this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveActivity);
.groupBy(trainingSurveys.productiveActivity),
const typeDistribution = await this.drizzle
// 6. Distribución por Tipo
this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ospType);
.groupBy(trainingSurveys.ospType),
// New Aggregations
const stateDistribution = await this.drizzle
// 7. Distribución por Estado (CORREGIDO con COALESCE)
this.drizzle
.select({
name: states.name,
value: sql<number>`count(${trainingSurveys.id})`
// Si states.name es NULL, devuelve 'Sin Asignar'
name: sql<string>`COALESCE(${states.name}, 'Sin Asignar')`,
value: sql<number>`count(${trainingSurveys.id})`,
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.where(whereCondition)
.groupBy(states.name);
// Importante: Agrupar también por el resultado del COALESCE o por states.name
.groupBy(states.name),
const yearDistribution = await this.drizzle
// 8. Distribución por Año
this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(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) })),
totalOsps: Number(totalOspsResult[0]?.count || 0),
totalProducers: Number(totalProducersResult[0]?.sum || 0),
totalProducts: Number(totalProductsResult[0]?.sum || 0), // Dato extraído del JSON
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),
})),
};
}
@@ -175,32 +210,160 @@ export class TrainingService {
.where(eq(trainingSurveys.id, id));
if (find.length === 0) {
throw new HttpException('Training record not found', HttpStatus.NOT_FOUND);
throw new HttpException(
'Training record not found',
HttpStatus.NOT_FOUND,
);
}
return find[0];
}
async create(createTrainingDto: CreateTrainingDto) {
private async saveFiles(files: Express.Multer.File[]): Promise<string[]> {
if (!files || files.length === 0) return [];
const savedPaths: string[] = [];
for (const file of files) {
const objectName = await this.minioService.upload(file, 'training');
const fileUrl = this.minioService.getPublicUrl(objectName);
savedPaths.push(fileUrl);
}
return savedPaths;
}
private async deleteFile(fileUrl: string) {
if (!fileUrl) return;
try {
// If it's a full URL, we need to extract the part after the bucket name
if (fileUrl.startsWith('http')) {
const url = new URL(fileUrl);
const pathname = url.pathname; // /bucket/folder/filename
const parts = pathname.split('/').filter(Boolean); // ['bucket', 'folder', 'filename']
// The first part is the bucket name, the rest is the object name
if (parts.length >= 2) {
const objectName = parts.slice(1).join('/');
await this.minioService.delete(objectName);
return;
}
}
// If it's not a URL or doesn't match the expected format, pass it as is
await this.minioService.delete(fileUrl);
} catch (error) {
// Fallback if URL parsing fails
await this.minioService.delete(fileUrl);
}
}
async create(
createTrainingDto: CreateTrainingDto,
files: Express.Multer.File[],
userId: number,
) {
// 1. Guardar fotos
const photoPaths = await this.saveFiles(files);
// 2. Extraer solo visitDate para formatearlo.
// Ya NO extraemos state, municipality, etc. porque no vienen en el DTO.
const { visitDate, state, municipality, parish, ...rest } =
createTrainingDto;
const [newRecord] = await this.drizzle
.insert(trainingSurveys)
.values({
...createTrainingDto,
visitDate: new Date(createTrainingDto.visitDate),
// Insertamos el resto de datos planos y las listas (arrays)
...rest,
// Conversión de fecha
visitDate: new Date(visitDate),
// 3. Asignar fotos de forma segura
photo1: photoPaths[0] ?? null,
photo2: photoPaths[1] ?? null,
photo3: photoPaths[2] ?? null,
state: Number(state) ?? null,
municipality: Number(municipality) ?? null,
parish: Number(parish) ?? null,
hasTransport: rest.hasTransport === 'true' ? true : false,
isOpenSpace: rest.isOpenSpace === 'true' ? true : false,
isExporting: rest.isExporting === 'true' ? true : false,
createdBy: userId,
updatedBy: userId,
})
.returning();
return newRecord;
}
async update(id: number, updateTrainingDto: UpdateTrainingDto) {
await this.findOne(id);
async update(
id: number,
updateTrainingDto: UpdateTrainingDto,
files: Express.Multer.File[],
userId: number,
) {
const currentRecord = await this.findOne(id);
const photoFields = ['photo1', 'photo2', 'photo3'] as const;
// 1. Guardar fotos nuevas en MinIO
const newFilePaths = await this.saveFiles(files);
const updateData: any = { ...updateTrainingDto };
// 2. Determinar el estado final de las fotos (diff)
// - Si el DTO tiene un valor (URL existente o ''), lo usamos.
// - Si el DTO no tiene el campo (undefined), mantenemos el de la DB.
const finalPhotos: (string | null)[] = photoFields.map((field) => {
const dtoValue = updateData[field];
if (dtoValue !== undefined) {
return dtoValue === '' ? null : dtoValue;
}
return currentRecord[field];
});
// 3. Asignar los nuevos paths subidos a los slots que quedaron vacíos
if (newFilePaths.length > 0) {
let newIdx = 0;
for (let i = 0; i < 3 && newIdx < newFilePaths.length; i++) {
if (!finalPhotos[i]) {
finalPhotos[i] = newFilePaths[newIdx];
newIdx++;
}
}
}
// 4. LIMPIEZA: Borrar de MinIO los archivos que ya no están en ningún slot
const oldPhotos = photoFields
.map((f) => currentRecord[f])
.filter((p): p is string => Boolean(p));
const newPhotosSet = new Set(finalPhotos.filter(Boolean));
for (const oldPath of oldPhotos) {
if (!newPhotosSet.has(oldPath)) {
await this.deleteFile(oldPath);
}
}
// 5. Preparar datos finales para la DB
updateData.photo1 = finalPhotos[0];
updateData.photo2 = finalPhotos[1];
updateData.photo3 = finalPhotos[2];
if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(updateTrainingDto.visitDate);
}
// actualizamos el id del usuario que actualizo el registro
updateData.updatedBy = userId;
updateData.hasTransport =
updateTrainingDto.hasTransport === 'true' ? true : false;
updateData.isOpenSpace =
updateTrainingDto.isOpenSpace === 'true' ? true : false;
updateData.isExporting =
updateTrainingDto.isExporting === 'true' ? true : false;
const [updatedRecord] = await this.drizzle
.update(trainingSurveys)
.set(updateData)
@@ -211,13 +374,314 @@ export class TrainingService {
}
async remove(id: number) {
await this.findOne(id);
const record = await this.findOne(id);
// Delete associated files
if (record.photo1) await this.deleteFile(record.photo1);
if (record.photo2) await this.deleteFile(record.photo2);
if (record.photo3) await this.deleteFile(record.photo3);
const [deletedRecord] = await this.drizzle
.delete(trainingSurveys)
.where(eq(trainingSurveys.id, id))
.returning();
return { message: 'Training record deleted successfully', data: deletedRecord };
return {
message: 'Training record deleted successfully',
data: deletedRecord,
};
}
// async exportTemplate() {
// const templatePath = path.join(
// __dirname,
// 'export_template',
// 'excel.osp.xlsx',
// );
// const templateBuffer = fs.readFileSync(templatePath);
// const workbook: any = await XlsxPopulate.fromDataAsync(templateBuffer);
// const sheet = workbook.sheet(0);
// 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))
// .execute();
// 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');
// sheet.cell(`A${currentRow}`).value(record.coorFullName);
// sheet.cell(`C${currentRow}`).value(dateStr);
// sheet.cell(`D${currentRow}`).value(timeStr);
// sheet.cell(`E${currentRow}`).value(record.stateName || '');
// sheet.cell(`F${currentRow}`).value(record.municipalityName || '');
// sheet.cell(`G${currentRow}`).value(record.parishName || '');
// sheet.cell(`H${currentRow}`).value(record.communeName);
// sheet.cell(`I${currentRow}`).value(record.siturCodeCommune);
// sheet.cell(`J${currentRow}`).value(record.communalCouncil);
// sheet.cell(`K${currentRow}`).value(record.siturCodeCommunalCouncil);
// sheet.cell(`L${currentRow}`).value(record.productiveActivity);
// sheet.cell(`M${currentRow}`).value(''); // requerimiento financiero description
// sheet.cell(`N${currentRow}`).value(record.ospName);
// sheet.cell(`O${currentRow}`).value(record.ospAddress);
// sheet.cell(`P${currentRow}`).value(record.ospRif);
// sheet.cell(`Q${currentRow}`).value(record.ospType);
// sheet.cell(`R${currentRow}`).value(record.currentStatus);
// sheet.cell(`S${currentRow}`).value(record.companyConstitutionYear);
// 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(', ');
// sheet.cell(`T${currentRow}`).value(totalProducers);
// sheet.cell(`U${currentRow}`).value(productsDesc);
// sheet.cell(`V${currentRow}`).value(record.infrastructureMt2);
// sheet.cell(`W${currentRow}`).value('');
// sheet.cell(`X${currentRow}`).value(record.paralysisReason || '');
// sheet.cell(`Y${currentRow}`).value(record.ospResponsibleFullname);
// sheet.cell(`Z${currentRow}`).value(record.ospResponsibleCedula);
// sheet.cell(`AA${currentRow}`).value(record.ospResponsibleRif);
// sheet.cell(`AB${currentRow}`).value(record.ospResponsiblePhone);
// sheet.cell(`AC${currentRow}`).value(record.ospResponsibleEmail);
// sheet.cell(`AD${currentRow}`).value(record.civilState);
// sheet.cell(`AE${currentRow}`).value(record.familyBurden);
// sheet.cell(`AF${currentRow}`).value(record.numberOfChildren);
// sheet.cell(`AG${currentRow}`).value(record.generalObservations || '');
// sheet.cell(`AH${currentRow}`).value(record.photo1 || '');
// sheet.cell(`AI${currentRow}`).value(record.photo2 || '');
// sheet.cell(`AJ${currentRow}`).value(record.photo3 || '');
// currentRow++;
// }
// return await workbook.outputAsync();
// }
// async exportTemplate(id: number) {
// // Validar que el registro exista
// const exist = await this.findOne(id);
// if (!exist) throw new NotFoundException(`No se encontro el registro`);
// // Obtener los datos del registro
// const records = await this.drizzle
// .select({
// // id: trainingSurveys.id,
// visitDate: trainingSurveys.visitDate,
// ospName: trainingSurveys.ospName,
// productiveSector: trainingSurveys.productiveSector,
// ospAddress: trainingSurveys.ospAddress,
// ospRif: trainingSurveys.ospRif,
// siturCodeCommune: trainingSurveys.siturCodeCommune,
// communeEmail: trainingSurveys.communeEmail,
// communeRif: trainingSurveys.communeRif,
// communeSpokespersonName: trainingSurveys.communeSpokespersonName,
// communeSpokespersonPhone: trainingSurveys.communeSpokespersonPhone,
// siturCodeCommunalCouncil: trainingSurveys.siturCodeCommunalCouncil,
// communalCouncilRif: trainingSurveys.communalCouncilRif,
// communalCouncilSpokespersonName:
// trainingSurveys.communalCouncilSpokespersonName,
// communalCouncilSpokespersonPhone:
// trainingSurveys.communalCouncilSpokespersonPhone,
// ospType: trainingSurveys.ospType,
// productiveActivity: trainingSurveys.productiveActivity, // Sector Productivo
// companyConstitutionYear: trainingSurveys.companyConstitutionYear,
// infrastructureMt2: trainingSurveys.infrastructureMt2,
// hasTransport: trainingSurveys.hasTransport,
// structureType: trainingSurveys.structureType,
// isOpenSpace: trainingSurveys.isOpenSpace,
// ospResponsibleFullname: trainingSurveys.ospResponsibleFullname,
// ospResponsibleCedula: trainingSurveys.ospResponsibleCedula,
// ospResponsiblePhone: trainingSurveys.ospResponsiblePhone,
// productList: trainingSurveys.productList,
// equipmentList: trainingSurveys.equipmentList,
// productionList: trainingSurveys.productionList,
// // photo1: trainingSurveys.photo1
// })
// .from(trainingSurveys)
// .where(eq(trainingSurveys.id, id));
// // .leftJoin(states, eq(trainingSurveys.state, states.id))
// // .leftJoin(municipalities,eq(trainingSurveys.municipality, municipalities.id))
// // .leftJoin(parishes, eq(trainingSurveys.parish, parishes.id))
// let equipmentList: any[] = Array.isArray(records[0].equipmentList)
// ? records[0].equipmentList
// : [];
// let productList: any[] = Array.isArray(records[0].productList)
// ? records[0].productList
// : [];
// let productionList: any[] = Array.isArray(records[0].productionList)
// ? records[0].productionList
// : [];
// console.log('equipmentList', equipmentList);
// console.log('productList', productList);
// console.log('productionList', productionList);
// let equipmentListArray: any[] = [];
// let productListArray: any[] = [];
// let productionListArray: any[] = [];
// const equipmentListCount = equipmentList.length;
// for (let i = 0; i < equipmentListCount; i++) {
// equipmentListArray.push([
// equipmentList[i].machine,
// '',
// equipmentList[i].quantity,
// ]);
// }
// const productListCount = productList.length;
// for (let i = 0; i < productListCount; i++) {
// productListArray.push([
// productList[i].productName,
// productList[i].dailyCount,
// productList[i].weeklyCount,
// productList[i].monthlyCount,
// ]);
// }
// const productionListCount = productionList.length;
// for (let i = 0; i < productionListCount; i++) {
// productionListArray.push([
// productionList[i].rawMaterial,
// '',
// productionList[i].quantity,
// ]);
// }
// // Ruta de la plantilla
// const templatePath = path.join(
// __dirname,
// 'export_template',
// 'excel.osp.xlsx',
// );
// // Cargar la plantilla
// const book = await XlsxPopulate.fromFileAsync(templatePath);
// const isoString = records[0].visitDate;
// const dateObj = new Date(isoString);
// const fechaFormateada = dateObj.toLocaleDateString('es-ES');
// const horaFormateada = dateObj.toLocaleTimeString('es-ES', {
// hour: '2-digit',
// minute: '2-digit',
// });
// // Llenar los datos
// book.sheet(0).cell('A6').value(records[0].productiveSector);
// book.sheet(0).cell('D6').value(records[0].ospName);
// book.sheet(0).cell('L5').value(fechaFormateada);
// book.sheet(0).cell('L6').value(horaFormateada);
// book.sheet(0).cell('B10').value(records[0].ospAddress);
// book.sheet(0).cell('C11').value(records[0].communeEmail);
// book.sheet(0).cell('C12').value(records[0].communeSpokespersonName);
// book.sheet(0).cell('G11').value(records[0].communeRif);
// book.sheet(0).cell('G12').value(records[0].communeSpokespersonPhone);
// book.sheet(0).cell('C13').value(records[0].siturCodeCommune);
// book.sheet(0).cell('G13').value(records[0].siturCodeCommunalCouncil);
// book.sheet(0).cell('G14').value(records[0].communalCouncilRif);
// book.sheet(0).cell('C15').value(records[0].communalCouncilSpokespersonName);
// book
// .sheet(0)
// .cell('G15')
// .value(records[0].communalCouncilSpokespersonPhone);
// book.sheet(0).cell('C16').value(records[0].ospType);
// book.sheet(0).cell('C17').value(records[0].ospName);
// book.sheet(0).cell('C18').value(records[0].productiveActivity);
// book.sheet(0).cell('C19').value('Proveedores');
// book.sheet(0).cell('C20').value(records[0].companyConstitutionYear);
// book.sheet(0).cell('C21').value(records[0].infrastructureMt2);
// book.sheet(0).cell('G17').value(records[0].ospRif);
// book
// .sheet(0)
// .cell(records[0].hasTransport === true ? 'J19' : 'L19')
// .value('X');
// book
// .sheet(0)
// .cell(records[0].structureType === 'CASA' ? 'J20' : 'L20')
// .value('X');
// book
// .sheet(0)
// .cell(records[0].isOpenSpace === true ? 'J21' : 'L21')
// .value('X');
// book.sheet(0).cell('A24').value(records[0].ospResponsibleFullname);
// book.sheet(0).cell('C24').value(records[0].ospResponsibleCedula);
// book.sheet(0).cell('E24').value(records[0].ospResponsiblePhone);
// book.sheet(0).cell('J24').value('N Femenino');
// book.sheet(0).cell('L24').value('N Masculino');
// book
// .sheet(0)
// .range(`A28:C${equipmentListCount + 28}`)
// .value(equipmentListArray);
// book
// .sheet(0)
// .range(`E28:G${productionListCount + 28}`)
// .value(productionListArray);
// book
// .sheet(0)
// .range(`I28:L${productListCount + 28}`)
// .value(productListArray);
// return book.outputAsync();
// }
}

View File

@@ -1,4 +1,6 @@
AUTH_URL = http://localhost:3000
AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE=
API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8000
NODE_ENV='development' #development | production

View File

@@ -0,0 +1,32 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { CreateTrainingForm } from '@/feactures/training/components/form';
import { useTrainingByIdQuery } from '@/feactures/training/hooks/use-training';
import { useParams, useRouter } from 'next/navigation';
export default function EditTrainingPage() {
const router = useRouter();
const params = useParams();
const id = Number(params.id);
const { data: training, isLoading } = useTrainingByIdQuery(id);
if (isLoading) {
return (
<PageContainer>
<div>Cargando...</div>
</PageContainer>
);
}
return (
<div className="p-6 space-y-6">
<CreateTrainingForm
defaultValues={training}
onSuccess={() => router.push('/dashboard/formulario')}
onCancel={() => router.back()}
/>
</div>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { CreateTrainingForm } from '@/feactures/training/components/form';
import { useRouter } from 'next/navigation';
export default function NewTrainingPage() {
const router = useRouter();
return (
// <PageContainer scrollable>
<div className="p-6 space-y-6">
<CreateTrainingForm
onSuccess={() => router.push('/dashboard/formulario')}
onCancel={() => router.back()}
/>
</div>
// </PageContainer>
);
}

View File

@@ -1,15 +1,39 @@
'use client';
// import PageContainer from '@/components/layout/page-container';
import { TrainingHeader } from '@/feactures/training/components/training-header';
import TrainingList from '@/feactures/training/components/training-list';
import TrainingTableAction from '@/feactures/training/components/training-tables/training-table-action';
import { searchParamsCache } from '@repo/shadcn/lib/searchparams';
import { SearchParams } from 'nuqs';
import PageContainer from '@/components/layout/page-container';
import { CreateTrainingForm } from '@/feactures/training/components/form';
import { env } from '@/lib/env';
const Page = () => {
return (
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<CreateTrainingForm />
</div>
);
export const metadata = {
title: 'Registro de OSP',
};
export default Page;
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page({ searchParams }: PageProps) {
const {
page,
q: searchQuery,
limit,
} = searchParamsCache.parse(await searchParams);
return (
// <PageContainer>
<div className="flex flex-1 flex-col space-y-6 p-6">
<TrainingHeader />
<TrainingTableAction />
<TrainingList
initialPage={page}
initialSearch={searchQuery}
initialLimit={limit || 10}
apiUrl={env.API_URL}
/>
</div>
// </PageContainer>
);
}

View File

@@ -15,7 +15,7 @@ import { useSession } from 'next-auth/react';
export const company = {
name: 'Sistema para Productores',
name: 'Sistema de Productores',
logo: GalleryVerticalEnd,
plan: 'FONDEMI',
};
@@ -42,15 +42,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
</SidebarHeader>
<SidebarContent>
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole} />
{AdministrationItems[0]?.role?.includes(userRole) &&
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole} />
}
{StatisticsItems[0]?.role?.includes(userRole) &&
<StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole} />
}
{AdministrationItems[0]?.role?.includes(userRole) &&
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole} />
}
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole} />
{/* <NavProjects projects={data.projects} /> */}
</SidebarContent>
<SidebarRail />

View File

@@ -0,0 +1,195 @@
export const COUNTRY_OPTIONS = [
'Afganistán',
'Albania',
'Alemania',
'Andorra',
'Angola',
'Antigua y Barbuda',
'Arabia Saudita',
'Argelia',
'Argentina',
'Armenia',
'Australia',
'Austria',
'Azerbaiyán',
'Bahamas',
'Bangladés',
'Barbados',
'Baréin',
'Bélgica',
'Belice',
'Benín',
'Bielorrusia',
'Birmania',
'Bolivia',
'Bosnia y Herzegovina',
'Botsuana',
'Brasil',
'Brunéi',
'Bulgaria',
'Burkina Faso',
'Burundi',
'Bután',
'Cabo Verde',
'Camboya',
'Camerún',
'Canadá',
'Catar',
'Chad',
'Chile',
'China',
'Chipre',
'Ciudad del Vaticano',
'Colombia',
'Comoras',
'Corea del Norte',
'Corea del Sur',
'Costa de Marfil',
'Costa Rica',
'Croacia',
'Cuba',
'Dinamarca',
'Dominica',
'Ecuador',
'Egipto',
'El Salvador',
'Emiratos Árabes Unidos',
'Eritrea',
'Eslovaquia',
'Eslovenia',
'España',
'Estados Unidos',
'Estonia',
'Etiopía',
'Filipinas',
'Finlandia',
'Fiyi',
'Francia',
'Gabón',
'Gambia',
'Georgia',
'Ghana',
'Granada',
'Grecia',
'Guatemala',
'Guyana',
'Guinea',
'Guinea Ecuatorial',
'Guinea-Bisáu',
'Haití',
'Honduras',
'Hungría',
'India',
'Indonesia',
'Irak',
'Irán',
'Irlanda',
'Islandia',
'Islas Marshall',
'Islas Salomón',
'Israel',
'Italia',
'Jamaica',
'Japón',
'Jordania',
'Kazajistán',
'Kenia',
'Kirguistán',
'Kiribati',
'Kuwait',
'Laos',
'Lesoto',
'Letonia',
'Líbano',
'Liberia',
'Libia',
'Liechtenstein',
'Lituania',
'Luxemburgo',
'Madagascar',
'Malasia',
'Malaui',
'Maldivas',
'Malí',
'Malta',
'Marruecos',
'Mauricio',
'Mauritania',
'México',
'Micronesia',
'Moldavia',
'Mónaco',
'Mongolia',
'Montenegro',
'Mozambique',
'Namibia',
'Nauru',
'Nepal',
'Nicaragua',
'Níger',
'Nigeria',
'Noruega',
'Nueva Zelanda',
'Omán',
'Países Bajos',
'Pakistán',
'Palaos',
'Panamá',
'Papúa Nueva Guinea',
'Paraguay',
'Perú',
'Polonia',
'Portugal',
'Reino Unido',
'República Centroafricana',
'República Checa',
'República de Macedonia',
'República del Congo',
'República Democrática del Congo',
'República Dominicana',
'República Sudafricana',
'Ruanda',
'Rumanía',
'Rusia',
'Samoa',
'San Cristóbal y Nieves',
'San Marino',
'San Vicente y las Granadinas',
'Santa Lucía',
'Santo Tomé y Príncipe',
'Senegal',
'Serbia',
'Seychelles',
'Sierra Leona',
'Singapur',
'Siria',
'Somalia',
'Sri Lanka',
'Suazilandia',
'Sudán',
'Sudán del Sur',
'Suecia',
'Suiza',
'Surinam',
'Tailandia',
'Tanzania',
'Tayikistán',
'Timor Oriental',
'Togo',
'Tonga',
'Trinidad y Tobago',
'Túnez',
'Turkmenistán',
'Turquía',
'Tuvalu',
'Ucrania',
'Uganda',
'Uruguay',
'Uzbekistán',
'Vanuatu',
'Vietnam',
'Yemen',
'Yibuti',
'Zambia',
'Zimbabue',
];

View File

@@ -10,32 +10,23 @@ export const GeneralItems: NavItem[] = [
isActive: false,
items: [], // No child items
},
{
title: 'ProduTienda',
url: '/dashboard/productos/',
icon: 'blocks',
shortcut: ['p', 'p'],
isActive: false,
items: [], // No child items
},
{
title: 'Formulario',
url: '/dashboard/formulario/',
icon: 'notepadText',
shortcut: ['p', 'p'],
isActive: false,
items: [], // No child items
},
// {
// title: 'ProduTienda',
// url: '/dashboard/productos/',
// icon: 'blocks',
// shortcut: ['p', 'p'],
// isActive: false,
// items: [], // No child items
// },
];
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', 'coordinators'], // sumatoria de los roles que si tienen acceso
items: [
{
@@ -50,7 +41,14 @@ export const AdministrationItems: NavItem[] = [
shortcut: ['l', 'l'],
url: '/dashboard/administracion/encuestas',
icon: 'login',
role: ['admin', 'superadmin', 'manager', 'user'],
role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'Registro OSP',
shortcut: ['p', 'p'],
url: '/dashboard/formulario/',
icon: 'notepadText',
role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'],
},
],
},
@@ -80,7 +78,7 @@ export const StatisticsItems: NavItem[] = [
role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'Socioproductiva',
title: 'Datos OSP',
shortcut: ['s', 's'],
url: '/dashboard/estadisticas/socioproductiva',
icon: 'blocks',
@@ -89,8 +87,3 @@ export const StatisticsItems: NavItem[] = [
],
},
];

View File

@@ -1,5 +1,6 @@
'use server';
import { safeFetchApi } from '@/lib';
import { cookies } from 'next/headers';
import { loginResponseSchema, UserFormValue } from '../schemas/login';
type LoginActionSuccess = {
@@ -17,7 +18,7 @@ type LoginActionSuccess = {
refresh_token: string;
refresh_expire_in: number;
};
}
};
type LoginActionError = {
type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí**
@@ -28,7 +29,7 @@ type LoginActionError = {
// Si SignInAction también puede devolver null, asegúralo en su tipo de retorno
type LoginActionResult = LoginActionSuccess | LoginActionError | null;
export const SignInAction = async (payload: UserFormValue): Promise<LoginActionResult> => {
export const SignInAction = async (payload: UserFormValue) => {
const [error, data] = await safeFetchApi(
loginResponseSchema,
'/auth/sign-in',
@@ -36,12 +37,22 @@ export const SignInAction = async (payload: UserFormValue): Promise<LoginActionR
payload,
);
if (error) {
return {
type: error.type as 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR',
message: error.message,
details: error.details
};
return error;
} else {
// 2. GUARDAR REFRESH TOKEN EN COOKIE (La clave del cambio)
(await cookies()).set(
'refresh_token',
String(data?.tokens?.refresh_token),
{
httpOnly: true, // JavaScript no puede leerla
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60, // Ej: 7 días (debe coincidir con tu backend)
},
);
return data;
}
};

View File

@@ -0,0 +1,47 @@
'use server';
// import { safeFetchApi } from '@/lib';
import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia
import { cookies } from 'next/headers';
import { logoutResponseSchema } from '../schemas/logout';
export const logoutAction = async (user_id: string) => {
try {
const response = await refreshApi.post('/auth/sign-out', { user_id });
const parsed = logoutResponseSchema.safeParse(response.data);
if (!parsed.success) {
console.error('Error de validación en la respuesta de refresh token:', {
errors: parsed.error.errors,
receivedData: response.data,
});
return null;
}
return parsed.data;
} catch (error: any) { // Captura el error para acceso a error.response
console.error('Error al cerrar sesion:', error.response?.data || error.message);
return null;
}
// const payload = { user_id };
// const [error, data] = await safeFetchApi(
// logoutResponseSchema,
// '/auth/sign-out',
// 'POST',
// payload,
// );
// if (error) {
// console.error('Error:', error);
// // Devuelve un objeto con la propiedad 'type' para que el callback de NextAuth lo reconozca como un error
// return {
// type: 'API_ERROR',
// message: error.message,
// };
// }
(await cookies()).delete('refresh_token');
};

View File

@@ -1,4 +1,3 @@
// auth/actions/refresh-token-action.ts
'use server';
import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia
import {

View File

@@ -72,7 +72,7 @@ export default function UserAuthForm() {
<form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<div className="text-center">
<h1 className="text-2xl font-bold">Sistema para productores</h1>
<h1 className="text-2xl font-bold">Sistema Gestión de Productores</h1>
<p className="text-balance text-muted-foreground hidden md:block">
Ingresa tus datos
</p>

View File

@@ -92,7 +92,7 @@ export default function UserAuthForm() {
<form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6">
<div className="items-center text-center">
<h1 className="text-2xl font-bold">Sistema para productores</h1>
<h1 className="text-2xl font-bold">Sistema Gestión de Productores</h1>
<p className="text-balance text-muted-foreground">
Ingresa tus datos
</p>
@@ -303,7 +303,7 @@ export default function UserAuthForm() {
<FormMessage className="text-red-500">{error}</FormMessage>
)}{' '}
<Button type="submit" className="w-full">
Registrarce
Registrarse
</Button>
<div className="text-center text-sm">
¿Ya tienes una cuenta?{" "}

View File

@@ -0,0 +1,5 @@
import z from 'zod';
export const logoutResponseSchema = z.object({
message: z.string(),
});

View File

@@ -4,13 +4,19 @@ import { tokensSchema } from './login';
// Esquema para el refresh token
export const refreshTokenSchema = z.object({
user_id: z.number(),
token: z.string(),
refreshToken: z.string(),
});
export type RefreshTokenValue = z.infer<typeof refreshTokenSchema>;
// Esquema final para la respuesta del backend
export const RefreshTokenResponseSchema = z.object({
tokens: tokensSchema,
});
// export const RefreshTokenResponseSchema = z.object({
// // tokens: tokensSchema,
// access_token: z.string(),
// access_expire_in: z.number(),
// refresh_token: z.string(),
// refresh_expire_in: z.number()
// });
export const RefreshTokenResponseSchema = tokensSchema

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

@@ -10,7 +10,7 @@ export const columns: ColumnDef<InventoryTable>[] = [
header: 'img',
cell: ({ row }) => {
return (
<img src={`/uploads/inventory/${row.original.userId}/${row.original.id}/${row.original.urlImg}`} alt="" width={64} height={64} className="rounded"/>
<img src={`/uploads/inventory/${row.original.userId}/${row.original.id}/${row.original.urlImg}`} alt="" width={64} height={64} className="rounded" />
)
},
},
@@ -27,7 +27,7 @@ export const columns: ColumnDef<InventoryTable>[] = [
{
accessorKey: 'price',
header: 'Precio',
cell: ({ row }) => `${row.original.price}$`
cell: ({ row }) => `${row.original.price} Bs.`
},
{
accessorKey: 'stock',

View File

@@ -21,9 +21,9 @@ import { useForm } from 'react-hook-form';
import { useUpdateProduct } from "@/feactures/inventory/hooks/use-mutation";
import { updateInventory, EditInventory, InventoryTable } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad
import { Textarea } from '@repo/shadcn/components/ui/textarea';
import {STATUS} from '@/constants/status'
import { STATUS } from '@/constants/status'
import { useState, useEffect } from 'react';
import {sizeFormate} from "@/feactures/inventory/utils/sizeFormate"
import { sizeFormate } from "@/feactures/inventory/utils/sizeFormate"
// import { z } from 'zod'; // Asegúrate de importar Zod
// --- MODIFICACIÓN CLAVE ---
@@ -154,7 +154,7 @@ export function UpdateForm({
<FormItem >
<FormLabel>Precio</FormLabel>
<FormControl>
<Input {...field} />
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -182,7 +182,7 @@ export function UpdateForm({
<FormItem className='col-span-2'>
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea {...field} className="resize-none"/>
<Textarea {...field} className="resize-none" />
</FormControl>
<FormMessage />
</FormItem>
@@ -196,7 +196,7 @@ export function UpdateForm({
<FormItem>
<FormLabel>Cantidad/Stock</FormLabel>
<FormControl>
<Input {...field} type="number" onChange={(e) => field.onChange(Number(e.target.value))}/>
<Input {...field} type="number" onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -32,7 +32,7 @@ export function ProductCard({ product, onClick }: cardProps) {
{product.status === 'AGOTADO' ? (
<p className="font-semibold text-lg text-red-900">AGOTADO</p>
) : ('')}
<p className="font-semibold text-lg">$ {product.price}</p>
<p className="font-semibold text-lg">{product.price} Bs.</p>
</CardFooter>
</Card>
)

View File

@@ -9,11 +9,11 @@ import {
CardTitle,
} from '@repo/shadcn/card';
export function ProductList({product}: {product: allProducts}) {
export function ProductList({ product }: { product: allProducts }) {
const [selectedImg, setSelectedImg] = useState(`/uploads/inventory/${product.userId}/${product.id}/${product.urlImg}`)
console.log(product);
return (
return (
// <PageContainer>
<main className='px-4 lg:px-6 flex flex-col md:flex-row gap-3 lg:gap-4 md:relative'>
<div className='w-full flex justify-between flex-col'>
@@ -53,7 +53,7 @@ return (
<CardTitle className="font-bold text-2xl text-primary">
{product.title.charAt(0).toUpperCase() + product.title.slice(1)}
</CardTitle>
<p className='font-semibold'>{product.price}$
<p className='font-semibold'>{product.price} Bs.
{product.status === 'AGOTADO' ? (
<span className="font-semibold text-lg text-red-900"> AGOTADO</span>
) : ('')}

View File

@@ -1,12 +1,14 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@repo/shadcn/button';
import { Heading } from '@repo/shadcn/heading';
import { Plus } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
export function SurveysHeader() {
const router = useRouter();
const { data: session } = useSession();
const role = session?.user.role[0]?.rol;
return (
<>
<div className="flex items-start justify-between">
@@ -14,11 +16,18 @@ export function SurveysHeader() {
title="Administración de Encuestas"
description="Gestiona las encuestas disponibles en la plataforma"
/>
<Button onClick={() => router.push(`/dashboard/administracion/encuestas/crear`)} size="sm">
<Plus className="h-4 w-4"/><span className='hidden sm:inline'>Agregar Encuesta</span>
{['superadmin', 'admin'].includes(role ?? '') && (
<Button
onClick={() =>
router.push(`/dashboard/administracion/encuestas/crear`)
}
size="sm"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Agregar Encuesta</span>
</Button>
)}
</div>
</>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertModal } from '@/components/modal/alert-modal';
import { useDeleteSurvey } from '@/feactures/surveys/hooks/use-mutation-surveys';
import { SurveyTable } from '@/feactures/surveys/schemas/survey';
import { Button } from '@repo/shadcn/button';
import {
Tooltip,
@@ -10,9 +10,9 @@ import {
TooltipTrigger,
} from '@repo/shadcn/tooltip';
import { Edit, Trash } from 'lucide-react';
import { SurveyTable } from '@/feactures/surveys/schemas/survey';
import { useDeleteSurvey } from '@/feactures/surveys/hooks/use-mutation-surveys';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
interface CellActionProps {
data: SurveyTable;
@@ -23,6 +23,7 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
const [open, setOpen] = useState(false);
const { mutate: deleteSurvey } = useDeleteSurvey();
const router = useRouter();
const { data: session } = useSession();
const onConfirm = async () => {
try {
@@ -36,6 +37,8 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
}
};
const role = session?.user.role[0]?.rol;
return (
<>
<AlertModal
@@ -47,15 +50,20 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
description="Esta acción no se puede deshacer."
/>
<div className="flex gap-1">
{['superadmin', 'admin'].includes(role ?? '') && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/dashboard/administracion/encuestas/editar/${data.id!}`)}
onClick={() =>
router.push(
`/dashboard/administracion/encuestas/editar/${data.id!}`,
)
}
>
<Edit className="h-4 w-4" />
</Button>
@@ -82,6 +90,8 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</>
);

View File

@@ -1,26 +1,30 @@
'use server';
import { safeFetchApi } from '@/lib/fetch.api';
import {
TrainingSchema,
TrainingMutate,
trainingApiResponseSchema
} from '../schemas/training';
import { trainingStatisticsResponseSchema } from '../schemas/statistics';
import {
TrainingMutate,
TrainingSchema,
trainingApiResponseSchema,
} from '../schemas/training';
export const getTrainingStatisticsAction = async (params: {
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.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(
@@ -32,8 +36,7 @@ export const getTrainingStatisticsAction = async (params: {
if (error) throw new Error(error.message);
return response?.data;
}
};
export const getTrainingAction = async (params: {
page?: number;
@@ -42,7 +45,6 @@ export const getTrainingAction = async (params: {
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
@@ -72,16 +74,27 @@ export const getTrainingAction = async (params: {
previousPage: null,
},
};
}
};
export const createTrainingAction = async (payload: TrainingSchema) => {
const { id, ...payloadWithoutId } = payload;
export const createTrainingAction = async (
payload: TrainingSchema | FormData,
) => {
let payloadToSend = payload;
let id: number | undefined;
if (payload instanceof FormData) {
payload.delete('id');
payloadToSend = payload;
} else {
const { id: _, ...rest } = payload;
payloadToSend = rest as any;
}
const [error, data] = await safeFetchApi(
TrainingMutate,
'/training',
'POST',
payloadWithoutId,
payloadToSend,
);
if (error) {
@@ -91,8 +104,21 @@ export const createTrainingAction = async (payload: TrainingSchema) => {
return data;
};
export const updateTrainingAction = async (payload: TrainingSchema) => {
const { id, ...payloadWithoutId } = payload;
export const updateTrainingAction = async (
payload: TrainingSchema | FormData,
) => {
let id: string | null = null;
let payloadToSend = payload;
if (payload instanceof FormData) {
id = payload.get('id') as string;
payload.delete('id');
payloadToSend = payload;
} else {
id = payload.id?.toString() || null;
const { id: _, ...rest } = payload;
payloadToSend = rest as any;
}
if (!id) throw new Error('ID es requerido para actualizar');
@@ -100,7 +126,7 @@ export const updateTrainingAction = async (payload: TrainingSchema) => {
TrainingMutate,
`/training/${id}`,
'PATCH',
payloadWithoutId,
payloadToSend,
);
if (error) {
@@ -114,10 +140,22 @@ export const deleteTrainingAction = async (id: number) => {
const [error] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'DELETE'
)
'DELETE',
);
if (error) throw new Error(error.message || 'Error al eliminar el registro');
return true;
}
};
export const getTrainingByIdAction = async (id: number) => {
const [error, response] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'GET',
);
if (error) throw new Error(error.message);
return response?.data;
};

View File

@@ -0,0 +1,175 @@
import { Button } from '@repo/shadcn/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@repo/shadcn/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { TrainingSchema } from '../schemas/training';
interface EquipmentItem {
machine: string;
quantity: string | number;
}
export function EquipmentList() {
const { control, register } = useFormContext<TrainingSchema>();
const { fields, append, remove } = useFieldArray({
control,
name: 'equipmentList',
});
const [isOpen, setIsOpen] = useState(false);
const [newItem, setNewItem] = useState<EquipmentItem>({
machine: '',
quantity: '',
});
const handleAdd = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (newItem.machine.trim()) {
append({
machine: newItem.machine,
quantity: newItem.quantity ? Number(newItem.quantity) : 0,
});
setNewItem({ machine: '', quantity: '' });
setIsOpen(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Datos del Equipamiento</h3>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" type="button">
Agregar Maquinaria
</Button>
</DialogTrigger>
<DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Agregar Maquinaria/Equipo</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Datos de equipamiento
</DialogDescription>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="modal-machine">Maquinaria</Label>
<Input
id="modal-machine"
value={newItem.machine}
onChange={(e) =>
setNewItem({ ...newItem, machine: e.target.value })
}
placeholder="Nombre de la maquinaria"
/>
</div>
<div className="space-y-2">
<Label htmlFor="modal-quantity">Cantidad</Label>
<Input
id="modal-quantity"
type="number"
value={newItem.quantity}
onChange={(e) =>
setNewItem({ ...newItem, quantity: e.target.value })
}
placeholder="0"
/>
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
>
Cancelar
</Button>
<Button type="button" onClick={handleAdd}>
Guardar
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Maquinaria</TableHead>
<TableHead>Cantidad</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`equipmentList.${index}.machine`)}
defaultValue={field.machine ?? ''}
/>
{field.machine}
</TableCell>
<TableCell>
<input
type="hidden"
{...register(`equipmentList.${index}.quantity`)}
defaultValue={field.quantity ?? ''}
/>
{field.quantity}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
type="button"
onClick={(e) => {
e.preventDefault();
remove(index);
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{fields.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center text-muted-foreground"
>
No hay equipamiento registrado
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
import { Button } from '@repo/shadcn/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@repo/shadcn/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { TrainingSchema } from '../schemas/training';
const UNIT_OPTIONS = ['KG', 'TON', 'UNID', 'LT', 'MTS', 'QQ', 'HM2', 'SACOS'];
// 1. Definimos la estructura de los datos para que TypeScript no se queje
// ProductItem y ProductFormValues locales eliminados en favor de TrainingSchema
export function ProductActivityList() {
const { control, register } = useFormContext<TrainingSchema>();
const { fields, append, remove } = useFieldArray({
control,
name: 'productList',
});
const [isOpen, setIsOpen] = useState(false);
// Modal Form State
const [newItem, setNewItem] = useState<any>({
description: '',
dailyCount: '',
weeklyCount: '',
monthlyCount: '',
});
const handleAdd = () => {
if (newItem.description) {
append(newItem);
setNewItem({
description: '',
dailyCount: '',
weeklyCount: '',
monthlyCount: '',
});
setIsOpen(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Datos de Actividad Productiva</h3>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">Agregar Producto/Actividad</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Producto Terminado</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Datos de actividad productiva
</DialogDescription>
<div className="space-y-6 py-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Descripción</Label>
<Input
value={newItem.description}
onChange={(e) =>
setNewItem({ ...newItem, description: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Cant. Diario</Label>
<Input
type="number"
value={newItem.dailyCount}
onChange={(e) =>
setNewItem({ ...newItem, dailyCount: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Cant. Semanal</Label>
<Input
type="number"
value={newItem.weeklyCount}
onChange={(e) =>
setNewItem({ ...newItem, weeklyCount: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Cant. Mensual</Label>
<Input
type="number"
value={newItem.monthlyCount}
onChange={(e) =>
setNewItem({ ...newItem, monthlyCount: e.target.value })
}
/>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={() => setIsOpen(false)}
>
Cancelar
</Button>
<Button onClick={handleAdd}>Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto/Descripción</TableHead>
<TableHead>Producción Diario</TableHead>
<TableHead>Producción Semanal</TableHead>
<TableHead>Producción Mensual</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`productList.${index}.description`)}
defaultValue={field.description ?? ''}
/>
<input
type="hidden"
{...register(`productList.${index}.dailyCount`)}
defaultValue={field.dailyCount ?? ''}
/>
<input
type="hidden"
{...register(`productList.${index}.weeklyCount`)}
defaultValue={field.weeklyCount ?? ''}
/>
<input
type="hidden"
{...register(`productList.${index}.monthlyCount`)}
defaultValue={field.monthlyCount ?? ''}
/>
{field.description}
</TableCell>
<TableCell>{field.dailyCount}</TableCell>
<TableCell>{field.weeklyCount}</TableCell>
<TableCell>{field.monthlyCount}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{fields.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
No hay productos registrados
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { Button } from '@repo/shadcn/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@repo/shadcn/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { TrainingSchema } from '../schemas/training';
const UNIT_OPTIONS = ['KG', 'TON', 'UNID', 'LT', 'MTS', 'QQ', 'HM2', 'SACOS'];
export function ProductionList() {
const { control, register } = useFormContext<TrainingSchema>();
const { fields, append, remove } = useFieldArray({
control,
name: 'productionList',
});
const [isOpen, setIsOpen] = useState(false);
const [newItem, setNewItem] = useState({
supplyType: '',
quantity: '',
unit: '',
});
const handleAdd = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (newItem.supplyType && newItem.quantity && newItem.unit) {
append({ ...newItem, quantity: Number(newItem.quantity) });
setNewItem({ supplyType: '', quantity: '', unit: '' });
setIsOpen(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Datos de Producción</h3>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">Agregar Producción</Button>
</DialogTrigger>
<DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Materia prima requerida (mensual)</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Datos de producción
</DialogDescription>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Tipo de Insumo/Rubro</Label>
<Input
value={newItem.supplyType}
onChange={(e) =>
setNewItem({ ...newItem, supplyType: e.target.value })
}
placeholder="Tipo"
/>
</div>
<div className="space-y-2">
<Label>Cantidad Mensual</Label>
<div className="flex gap-2">
<Input
type="number"
className="flex-1"
value={newItem.quantity}
onChange={(e) =>
setNewItem({ ...newItem, quantity: e.target.value })
}
placeholder="0"
/>
<Select
value={newItem.unit}
onValueChange={(val) =>
setNewItem({ ...newItem, unit: val })
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Unidad" />
</SelectTrigger>
<SelectContent>
{UNIT_OPTIONS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleAdd}
disabled={
!newItem.supplyType || !newItem.quantity || !newItem.unit
}
>
Guardar
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tipo Insumo</TableHead>
<TableHead>Cantidad (Mensual)</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.supplyType`)}
defaultValue={field.supplyType ?? ''}
/>
{field.supplyType}
</TableCell>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.quantity`)}
defaultValue={field.quantity ?? ''}
/>
<input
type="hidden"
{...register(`productionList.${index}.unit`)}
defaultValue={field.unit ?? ''}
/>
{field.quantity} {field.unit}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
type="button"
onClick={(e) => {
e.preventDefault();
remove(index);
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{fields.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
No hay datos de producción registrados
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
'use client';
import { Heading } from '@repo/shadcn/heading';
export function TrainingHeader() {
return (
<div className="flex items-start justify-between mb-2">
<Heading
title="Registro de Organizaciones Socioproductivas"
description="Gestiona los registros de las OSP"
/>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { DataTable } from '@repo/shadcn/table/data-table';
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
import { useTrainingQuery } from '../hooks/use-training';
import { columns } from './training-tables/columns';
interface TrainingListProps {
initialPage: number;
initialSearch?: string | null;
initialLimit: number;
apiUrl: string;
}
export default function TrainingList({
initialPage,
initialSearch,
initialLimit,
apiUrl,
}: TrainingListProps) {
const filters = {
page: initialPage,
limit: initialLimit,
...(initialSearch && { search: initialSearch }),
};
const { data, isLoading } = useTrainingQuery(filters);
if (isLoading) {
return <DataTableSkeleton columnCount={5} rowCount={initialLimit} />;
}
const transformedData =
data?.data?.map((item) => ({
...item,
communeRif: item.communeRif || '',
communeSpokespersonName: item.communeSpokespersonName || '',
communalCouncilRif: item.communalCouncilRif || '',
})) || [];
return (
<DataTable
columns={columns({ apiUrl })}
data={transformedData}
totalItems={data?.meta.totalCount || 0}
pageSizeOptions={[10, 20, 30, 40, 50]}
/>
);
}

View File

@@ -1,13 +1,19 @@
'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 {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@repo/shadcn/card';
import { Input } from '@repo/shadcn/input';
import {
Select,
SelectContent,
@@ -15,6 +21,22 @@ import {
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { SelectSearchable } from '@repo/shadcn/select-searchable';
import { useState } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useTrainingStatsQuery } from '../hooks/use-training-statistics';
const OSP_TYPES = [
'EPSD',
@@ -39,10 +61,12 @@ export function TrainingStatistics() {
const { data: dataParish } = useParishQuery(municipalityId);
const stateOptions = dataState?.data || [{ id: 0, name: 'Sin estados' }];
const municipalityOptions = Array.isArray(dataMunicipality?.data) && dataMunicipality.data.length > 0
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
const parishOptions =
Array.isArray(dataParish?.data) && dataParish.data.length > 0
? dataParish.data
: [{ id: 0, stateId: 0, name: 'Sin Parroquias' }];
@@ -66,16 +90,35 @@ export function TrainingStatistics() {
};
if (isLoading) {
return <div className="flex justify-center p-8">Cargando estadísticas...</div>;
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>;
return (
<div className="flex justify-center p-8">No hay datos disponibles.</div>
);
}
const { totalOsps, totalProducers, statusDistribution, activityDistribution, typeDistribution, stateDistribution, yearDistribution } = data;
const {
totalOsps,
totalProducers,
statusDistribution,
activityDistribution,
typeDistribution,
stateDistribution,
yearDistribution,
} = data;
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'];
const COLORS = [
'#0088FE',
'#00C49F',
'#FFBB28',
'#FF8042',
'#8884d8',
'#82ca9d',
];
return (
<div className="space-y-6">
@@ -115,7 +158,7 @@ export function TrainingStatistics() {
setParishId(0); // Reset parish
}}
placeholder="Selecciona un estado"
defaultValue={stateId ? stateId.toString() : ""}
defaultValue={stateId ? stateId.toString() : ''}
/>
</div>
<div className="space-y-2">
@@ -130,7 +173,7 @@ export function TrainingStatistics() {
setParishId(0);
}}
placeholder="Selecciona municipio"
defaultValue={municipalityId ? municipalityId.toString() : ""}
defaultValue={municipalityId ? municipalityId.toString() : ''}
disabled={!stateId || stateId === 0}
/>
</div>
@@ -143,7 +186,7 @@ export function TrainingStatistics() {
}))}
onValueChange={(value: any) => setParishId(Number(value))}
placeholder="Selecciona parroquia"
defaultValue={parishId ? parishId.toString() : ""}
defaultValue={parishId ? parishId.toString() : ''}
disabled={!municipalityId || municipalityId === 0}
/>
</div>
@@ -155,8 +198,10 @@ export function TrainingStatistics() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
{OSP_TYPES.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem>
{OSP_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -174,7 +219,9 @@ export function TrainingStatistics() {
<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>
<CardTitle className="text-sm font-medium">
Total de OSP Registradas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalOsps}</div>
@@ -185,7 +232,9 @@ export function TrainingStatistics() {
</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>
<CardTitle className="text-sm font-medium">
Total de Productores
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProducers}</div>
@@ -198,7 +247,9 @@ export function TrainingStatistics() {
<Card className="col-span-full">
<CardHeader>
<CardTitle>Actividad Productiva</CardTitle>
<CardDescription>Distribución por tipo de actividad</CardDescription>
<CardDescription>
Distribución por tipo de actividad
</CardDescription>
</CardHeader>
<CardContent className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
@@ -219,7 +270,7 @@ export function TrainingStatistics() {
</Card>
{/* State Distribution */}
<Card className="col-span-full">
{/* <Card className="col-span-full">
<CardHeader>
<CardTitle>Distribución por Estado</CardTitle>
<CardDescription>OSP registradas por estado</CardDescription>
@@ -239,7 +290,7 @@ export function TrainingStatistics() {
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</Card> */}
{/* Year Distribution */}
<Card className="col-span-full lg:col-span-1">
@@ -283,7 +334,10 @@ export function TrainingStatistics() {
dataKey="value"
>
{statusDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip wrapperStyle={{ color: '#000' }} />

View File

@@ -0,0 +1,149 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { useDeleteTraining } from '@/feactures/training/hooks/use-training';
import { TrainingSchema } from '@/feactures/training/schemas/training';
import { Button } from '@repo/shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn/tooltip';
import { Edit, Eye, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { TrainingViewModal } from '../training-view-modal';
interface CellActionProps {
data: TrainingSchema;
apiUrl: string;
}
export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const { mutate: deleteTraining } = useDeleteTraining();
const router = useRouter();
const { data: session } = useSession();
const onConfirm = async () => {
try {
setLoading(true);
deleteTraining(data.id!);
setOpen(false);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
// Mapear roles a minúsculas para comparación segura
const userRoles = session?.user?.role?.map((r) => r.rol.toLowerCase()) || [];
const isAdminOrSuper = userRoles.some((r) =>
['superadmin', 'admin'].includes(r),
);
// Soporta tanto 'coordinator' como 'coordinador'
const isCoordinator = userRoles.some(r =>
r.includes('coordinator') || r.includes('coordinador')
);
const isOtherAuthorized = userRoles.some((r) =>
['autoridad', 'manager'].includes(r),
);
// El creador del registro: intentamos createdBy o created_by por si acaso
const createdBy = data.createdBy ?? (data as any).created_by;
// Comparación robusta de IDs
const isOwner = createdBy !== undefined &&
createdBy !== null &&
Number(createdBy) === Number(session?.user?.id);
return (
<>
<AlertModal
isOpen={open}
onClose={() => setOpen(false)}
onConfirm={onConfirm}
loading={loading}
title="¿Estás seguro que desea eliminar este registro?"
description="Esta acción no se puede deshacer."
/>
<TrainingViewModal
isOpen={viewOpen}
onClose={() => setViewOpen(false)}
data={data}
/>
<div className="flex gap-1">
{/* VER DETALLE: superadmin, admin, autoridad, manager, or owner coordinator */}
{(isAdminOrSuper || isOtherAuthorized || (isCoordinator && isOwner)) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setViewOpen(true)}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ver detalle</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* EDITAR: Superadmin, admin OR (coordinator if owner) */}
{(isAdminOrSuper || (isCoordinator && isOwner)) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
router.push(`/dashboard/formulario/editar/${data.id}`)
}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Editar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* ELIMINAR: Solo superadmin y admin */}
{isAdminOrSuper && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(true)}
>
<Trash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Eliminar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,60 @@
'use client';
import { TrainingSchema } from '@/feactures/training/schemas/training';
import { Badge } from '@repo/shadcn/badge';
import { ColumnDef } from '@tanstack/react-table';
import { CellAction } from './cell-action';
interface ColumnsProps {
apiUrl: string;
}
export function columns({ apiUrl }: ColumnsProps): ColumnDef<TrainingSchema>[] {
return [
{
accessorKey: 'ospName',
header: 'Nombre OSP',
},
{
accessorKey: 'ospRif',
header: 'RIF',
},
{
accessorKey: 'ospType',
header: 'Tipo',
},
{
accessorKey: 'created_at',
header: 'Fecha de creación',
cell: ({ row }) => {
// console.log(row.getValue('created_at'));
const date = row.getValue('created_at') as string;
return date ? new Date(date).toLocaleString() : 'N/A';
},
},
{
accessorKey: 'currentStatus',
header: 'Estatus',
cell: ({ row }) => {
const status = row.getValue('currentStatus') as string;
return (
<Badge variant={status === 'ACTIVA' ? 'default' : 'secondary'}>
{status}
</Badge>
);
},
},
{
accessorKey: 'visitDate',
header: 'Fecha Visita',
cell: ({ row }) => {
const date = row.getValue('visitDate') as string;
return date ? new Date(date).toLocaleString() : 'N/A';
},
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => <CellAction data={row.original} apiUrl={apiUrl} />,
},
];
}

View File

@@ -0,0 +1,38 @@
'use client';
import { Button } from '@repo/shadcn/components/ui/button';
import { DataTableSearch } from '@repo/shadcn/table/data-table-search';
import { Plus } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useTrainingTableFilters } from './use-training-table-filters';
export default function TrainingTableAction() {
const { searchQuery, setPage, setSearchQuery } = useTrainingTableFilters();
const router = useRouter();
const { data: session } = useSession();
const role = session?.user.role[0]?.rol;
return (
<div className="flex items-center justify-between mt-4 ">
<div className="flex items-center gap-4 flex-grow">
<DataTableSearch
searchKey="nombre"
searchQuery={searchQuery || ''}
setSearchQuery={setSearchQuery}
setPage={setPage}
/>
</div>{' '}
{['superadmin', 'autoridad', 'admin', 'manager', 'coordinators'].includes(
role ?? '',
) && (
<Button
onClick={() => router.push(`/dashboard/formulario/nuevo`)}
size="sm"
>
<Plus className="h-4 w-4" />
<span className="hidden md:inline">Nuevo Registro</span>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { searchParams } from '@repo/shadcn/lib/searchparams';
import { useQueryState } from 'nuqs';
import { useCallback, useMemo } from 'react';
export function useTrainingTableFilters() {
const [searchQuery, setSearchQuery] = useQueryState(
'q',
searchParams.q
.withOptions({
shallow: false,
throttleMs: 500,
})
.withDefault(''),
);
const [page, setPage] = useQueryState(
'page',
searchParams.page.withDefault(1),
);
const resetFilters = useCallback(() => {
setSearchQuery(null);
setPage(1);
}, [setSearchQuery, setPage]);
const isAnyFilterActive = useMemo(() => {
return !!searchQuery;
}, [searchQuery]);
return {
searchQuery,
setSearchQuery,
page,
setPage,
resetFilters,
isAnyFilterActive,
};
}

View File

@@ -0,0 +1,550 @@
'use client';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
import { Badge } from '@repo/shadcn/badge';
import { Button } from '@repo/shadcn/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@repo/shadcn/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@repo/shadcn/components/ui/dialog';
import { ScrollArea } from '@repo/shadcn/components/ui/scroll-area';
import {
ExternalLink,
Factory,
MapPin,
Package,
Wrench,
X,
} from 'lucide-react';
import React, { useState } from 'react';
import { TrainingSchema } from '../schemas/training';
interface TrainingViewModalProps {
data: TrainingSchema | null;
isOpen: boolean;
onClose: () => void;
}
export function TrainingViewModal({
data,
isOpen,
onClose,
}: TrainingViewModalProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const { data: statesData } = useStateQuery();
const { data: municipalitiesData } = useMunicipalityQuery(data?.state || 0);
const { data: parishesData } = useParishQuery(data?.municipality || 0);
if (!data) return null;
const stateName = statesData?.data?.find(
(s: any) => s.id === data.state,
)?.name;
const municipalityName = municipalitiesData?.data?.find(
(m: any) => m.id === data.municipality,
)?.name;
const parishName = parishesData?.data?.find(
(p: any) => p.id === data.parish,
)?.name;
const DetailItem = ({ label, value }: { label: string; value: any }) => (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</p>
<p className="text-sm font-semibold text-foreground break-words">
{value !== null && value !== undefined && value !== '' ? value : 'N/A'}
</p>
</div>
);
const Section = ({
title,
icon: Icon,
children,
}: {
title: string;
icon?: React.ElementType;
children: React.ReactNode;
}) => (
<Card className="overflow-hidden border-l-4 border-l-primary/20">
<CardHeader className="py-3 bg-muted/30">
<CardTitle className="text-lg flex items-center gap-2">
{Icon && <Icon className="h-5 w-5 text-primary" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-6 pt-4">
{children}
</CardContent>
</Card>
);
const BooleanBadge = ({ value }: { value?: boolean | null }) => (
<Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Sí' : 'No'}
</Badge>
);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[1000px] max-h-[90vh] p-0 flex flex-col">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Factory className="h-6 w-6" />
{data.ospName}
</DialogTitle>
<DialogDescription>
{data.ospType} {data.ospRif} {' '}
<span
className={
data.currentStatus === 'ACTIVA'
? 'text-green-600 font-medium'
: 'text-red-600'
}
>
{data.currentStatus}
</span>
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 px-6 py-6">
<div className="space-y-8">
{/* 1. Datos de la Visita */}
<Section title="Datos de la Visita">
<DetailItem label="Coordinador" value={data.coorFullName} />
<DetailItem label="Teléfono Coord." value={data.coorPhone} />
<DetailItem
label="Fecha Visita"
value={
data.visitDate
? new Date(data.visitDate).toLocaleString()
: 'N/A'
}
/>
<DetailItem label="Estado" value={stateName} />
<DetailItem label="Municipio" value={municipalityName} />
<DetailItem label="Parroquia" value={parishName} />
</Section>
{/* 2. Sectores y Actividad */}
<Section title="Sectores Económicos">
<DetailItem label="Sector Económico" value={data.ecoSector} />
<DetailItem
label="Sector Productivo"
value={data.productiveSector}
/>
<DetailItem
label="Actividad Central"
value={data.centralProductiveActivity}
/>
<DetailItem
label="Actividad Principal"
value={data.mainProductiveActivity}
/>
<div className="col-span-full">
<DetailItem
label="Actividad Específica"
value={data.productiveActivity}
/>
</div>
</Section>
{/* 3. Infraestructura y Ubicación */}
<Section title="Infraestructura y Ubicación" icon={MapPin}>
<DetailItem
label="Año Constitución"
value={data.companyConstitutionYear}
/>
<DetailItem
label="Infraestructura (m²)"
value={data.infrastructureMt2}
/>
<DetailItem
label="Tipo Estructura"
value={data.structureType}
/>
<DetailItem
label="Posee Transporte"
value={<BooleanBadge value={data.hasTransport} />}
/>
<DetailItem
label="Espacio Abierto"
value={<BooleanBadge value={data.isOpenSpace} />}
/>
<div className="col-span-full space-y-4 mt-2">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase">
Dirección
</p>
<p className="text-sm font-medium">{data.ospAddress}</p>
</div>
{data.ospGoogleMapsLink && (
<Button
variant="outline"
size="sm"
asChild
className="gap-2"
>
<a
href={
data.ospGoogleMapsLink.startsWith('http')
? data.ospGoogleMapsLink
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(data.ospGoogleMapsLink)}`
}
target="_blank"
rel="noreferrer"
>
<MapPin className="h-4 w-4" />
Ver en Google Maps
<ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
</div>
</Section>
{/* 4. LISTAS DETALLADAS (Lo nuevo) */}
{/* PRODUCTOS */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5" />
Productos Registrados
<Badge variant="secondary" className="ml-2">
{data.productList?.length || 0}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.productList?.map((prod: any, idx: number) => (
<div
key={idx}
className="bg-muted/40 p-4 rounded-lg border text-sm"
>
<h4 className="font-bold text-base text-primary mb-2">
{prod.description}
</h4>
<div className="grid grid-cols-3 gap-2">
<DetailItem label="Diario" value={prod.dailyCount} />
<DetailItem
label="Semanal"
value={prod.weeklyCount}
/>
<DetailItem
label="Mensual"
value={prod.monthlyCount}
/>
</div>
</div>
))}
</div>
{(!data.productList || data.productList.length === 0) && (
<p className="text-sm text-muted-foreground italic">
No hay productos registrados.
</p>
)}
</CardContent>
</Card>
{/* DISTRIBUCIÓN, EXPORTACIÓN Y MANO DE OBRA */}
<Section title="Distribución, Exportación y Mano de Obra">
<div className="col-span-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-4">
<h4 className="text-sm font-bold border-b pb-1">
Distribución Interna
</h4>
<DetailItem
label="Zona de Distribución"
value={data.internalDistributionZone}
/>
</div>
<div className="space-y-4">
<h4 className="text-sm font-bold border-b pb-1">
Mano de Obra
</h4>
<div className="grid grid-cols-2 gap-4">
<DetailItem label="Mujeres" value={data.womenCount} />
<DetailItem label="Hombres" value={data.menCount} />
<DetailItem
label="Total"
value={
Number(data.womenCount || 0) +
Number(data.menCount || 0)
}
/>
</div>
</div>
<div className="space-y-4">
<h4 className="text-sm font-bold border-b pb-1 flex items-center gap-2">
Exportación <BooleanBadge value={data.isExporting} />
</h4>
{data.isExporting && (
<div className="space-y-3">
<DetailItem label="País" value={data.externalCountry} />
<DetailItem label="Ciudad" value={data.externalCity} />
<DetailItem
label="Descripción"
value={data.externalDescription}
/>
<div className="grid grid-cols-2 gap-2">
<DetailItem
label="Cantidad"
value={data.externalQuantity}
/>
<DetailItem
label="Unidad"
value={data.externalUnit}
/>
</div>
</div>
)}
</div>
</div>
</Section>
{/* EQUIPAMIENTO Y PRODUCCIÓN */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Wrench className="h-5 w-5" />
Equipamiento
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.equipmentList?.map((eq: any, idx: number) => (
<div
key={idx}
className="flex justify-between items-center p-2 rounded bg-muted/40 border"
>
<div>
<p className="font-medium">{eq.machine}</p>
<p className="text-xs text-muted-foreground">
{eq.specifications}
</p>
</div>
<Badge
variant="outline"
className="text-sm font-bold h-8 w-8 flex items-center justify-center rounded-full"
>
{eq.quantity}
</Badge>
</div>
))}
{(!data.equipmentList ||
data.equipmentList.length === 0) && (
<p className="text-sm text-muted-foreground italic">
No hay equipamiento registrado.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Factory className="h-5 w-5" />
Materia Prima
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.productionList?.map((mat: any, idx: number) => (
<div
key={idx}
className="flex justify-between items-center p-2 rounded bg-muted/40 border"
>
<div>
<p className="font-medium">{mat.rawMaterial}</p>
<p className="text-xs text-muted-foreground">
{mat.supplyType}
</p>
</div>
<Badge variant="secondary">
Cant: {mat.quantity} {mat.unit}
</Badge>
</div>
))}
{(!data.productionList ||
data.productionList.length === 0) && (
<p className="text-sm text-muted-foreground italic">
No hay materia prima registrada.
</p>
)}
</CardContent>
</Card>
</div>
{/* 5. Comuna y Responsable */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section title="Datos de la Comuna">
<DetailItem label="Comuna" value={data.communeName} />
<DetailItem
label="Código SITUR"
value={data.siturCodeCommune}
/>
<DetailItem
label="Vocero"
value={data.communeSpokespersonName}
/>
<DetailItem
label="Teléfono"
value={data.communeSpokespersonPhone}
/>
<div className="col-span-full border-t pt-4 mt-2">
<DetailItem
label="Consejo Comunal"
value={data.communalCouncil}
/>
<DetailItem
label="Vocero C.C."
value={data.communalCouncilSpokespersonName}
/>
</div>
</Section>
<Section title="Responsable OSP">
<DetailItem
label="Nombre"
value={data.ospResponsibleFullname}
/>
<DetailItem
label="Cédula"
value={data.ospResponsibleCedula}
/>
<DetailItem
label="Teléfono"
value={data.ospResponsiblePhone}
/>
{/* <DetailItem label="Email" value={data.ospResponsibleEmail} />
<DetailItem
label="Carga Familiar"
value={data.familyBurden}
/>
<DetailItem label="Hijos" value={data.numberOfChildren} /> */}
</Section>
</div>
{/* 6. Observaciones */}
{(data.generalObservations || data.paralysisReason) && (
<Card>
<CardHeader>
<CardTitle>Observaciones</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data.generalObservations && (
<div>
<p className="text-xs font-bold text-muted-foreground uppercase mb-1">
Generales
</p>
<p className="text-sm">{data.generalObservations}</p>
</div>
)}
{data.paralysisReason && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-md border border-red-200 dark:border-red-900">
<p className="text-xs font-bold text-red-600 dark:text-red-400 uppercase mb-1">
Motivo de Paralización
</p>
<p className="text-sm">{data.paralysisReason}</p>
</div>
)}
</CardContent>
</Card>
)}
{/* 7. Fotos */}
<Section title="Registro Fotográfico">
{[data.photo1, data.photo2, data.photo3].some(Boolean) ? (
<div className="col-span-full grid grid-cols-1 sm:grid-cols-3 gap-4">
{[data.photo1, data.photo2, data.photo3].map(
(photo, idx) =>
photo && (
<div
key={idx}
className="relative aspect-video rounded-lg overflow-hidden cursor-zoom-in border hover:shadow-lg transition-all"
onClick={() => setSelectedImage(photo)}
>
<img
src={`${photo}`}
alt={`Evidencia ${idx + 1}`}
className="object-cover w-full h-full"
/>
</div>
),
)}
</div>
) : (
<p className="text-sm text-muted-foreground col-span-full">
No hay imágenes cargadas.
</p>
)}
</Section>
</div>
</ScrollArea>
<DialogFooter className="px-6 py-4 border-t bg-muted/20">
<Button
onClick={onClose}
variant="outline"
className="w-full sm:w-auto"
>
Cerrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Lightbox para imágenes */}
<Dialog
open={!!selectedImage}
onOpenChange={() => setSelectedImage(null)}
>
<DialogContent className="max-w-[95vw] max-h-[95vh] p-0 overflow-hidden bg-black/95 border-none">
<DialogHeader className="sr-only">
<DialogTitle>Imagen Ampliada</DialogTitle>
</DialogHeader>
<DialogDescription></DialogDescription>
<div className="relative w-full h-full flex items-center justify-center p-2">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 text-white hover:bg-white/20 rounded-full z-50"
onClick={() => setSelectedImage(null)}
>
<X className="h-6 w-6" />
</Button>
{selectedImage && (
<img
src={`${selectedImage}`}
alt="Vista ampliada"
className="max-w-full max-h-[90vh] object-contain rounded-md"
/>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,171 @@
export const SECTOR_ECONOMICO = {
PRIMARIO: 'PRIMARIO',
SECUNDARIO: 'SECUNDARIO',
TERCIARIO: 'TERCIARIO',
} as const;
export const SECTOR_PRODUCTIVO = {
AGRICOLA: 'AGRÍCOLA',
MANUFACTURA: 'MANUFACTURA',
SERVICIOS: 'SERVICIOS',
TURISMO: 'TURISMO',
COMERCIO: 'COMERCIO',
} as const;
export const ACTIVIDAD_CENTRAL = {
PRODUCCION_VEGETAL: 'PRODUCCIÓN VEGETAL',
PRODUCCION_ANIMAL: 'PRODUCCIÓN ANIMAL',
PRODUCCION_VEGETAL_ANIMAL: 'PRODUCCIÓN VEGETAL Y ANIMAL',
INDUSTRIAL: 'INDUSTRIAL',
SERVICIOS: 'SERVICIOS',
TURISMO: 'TURISMO',
COMERCIO: 'COMERCIO',
} as const;
export const ACTIVIDAD_PRINCIPAL = {
AGRICULTURA: 'AGRICULTURA',
CRIA: 'CRIA',
PATIOS_PRODUCTIVOS: 'PATIOS PRODUCTIVOS O CONUCOS',
TRANSFORMACION_MATERIA: 'TRANSFORMACION DE LA MATERIA PRIMA',
TEXTIL: 'TALLER DE COFECCION TEXTIL',
CONSTRUCCION: 'CONSTRUCCION',
BIENES_SERVICIOS: 'OFRECER PRODUCTOS DE BIENES Y SERVICIOS',
VISITAS_GUIADAS: 'VISITAS GUIADAS',
ALOJAMIENTO: 'ALOJAMIENTO',
TURISMO: 'TURISMO',
COMERCIO: 'COMERCIO',
} as const;
export const SECTOR_ECONOMICO_OPTIONS = [
SECTOR_ECONOMICO.PRIMARIO,
SECTOR_ECONOMICO.SECUNDARIO,
SECTOR_ECONOMICO.TERCIARIO,
];
// Map: Sector Economico -> Productive Sectors
export const SECTOR_PRODUCTIVO_MAP: Record<string, string[]> = {
[SECTOR_ECONOMICO.PRIMARIO]: [SECTOR_PRODUCTIVO.AGRICOLA],
[SECTOR_ECONOMICO.SECUNDARIO]: [SECTOR_PRODUCTIVO.MANUFACTURA],
[SECTOR_ECONOMICO.TERCIARIO]: [
SECTOR_PRODUCTIVO.SERVICIOS,
SECTOR_PRODUCTIVO.TURISMO,
SECTOR_PRODUCTIVO.COMERCIO,
],
};
// Map: Productive Sector -> Central Productive Activity
export const ACTIVIDAD_CENTRAL_MAP: Record<string, string[]> = {
[SECTOR_PRODUCTIVO.AGRICOLA]: [
ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL,
ACTIVIDAD_CENTRAL.PRODUCCION_ANIMAL,
ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL_ANIMAL,
],
[SECTOR_PRODUCTIVO.MANUFACTURA]: [ACTIVIDAD_CENTRAL.INDUSTRIAL],
[SECTOR_PRODUCTIVO.SERVICIOS]: [ACTIVIDAD_CENTRAL.SERVICIOS],
[SECTOR_PRODUCTIVO.TURISMO]: [ACTIVIDAD_CENTRAL.TURISMO],
[SECTOR_PRODUCTIVO.COMERCIO]: [ACTIVIDAD_CENTRAL.COMERCIO],
};
// Map: Central Productive Activity -> Main Productive Activity
export const ACTIVIDAD_PRINCIPAL_MAP: Record<string, string[]> = {
[ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL]: [ACTIVIDAD_PRINCIPAL.AGRICULTURA],
[ACTIVIDAD_CENTRAL.PRODUCCION_ANIMAL]: [ACTIVIDAD_PRINCIPAL.CRIA],
[ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL_ANIMAL]: [
ACTIVIDAD_PRINCIPAL.PATIOS_PRODUCTIVOS,
],
[ACTIVIDAD_CENTRAL.INDUSTRIAL]: [
ACTIVIDAD_PRINCIPAL.TRANSFORMACION_MATERIA,
ACTIVIDAD_PRINCIPAL.TEXTIL,
ACTIVIDAD_PRINCIPAL.CONSTRUCCION,
],
[ACTIVIDAD_CENTRAL.SERVICIOS]: [ACTIVIDAD_PRINCIPAL.BIENES_SERVICIOS],
[ACTIVIDAD_CENTRAL.TURISMO]: [
ACTIVIDAD_PRINCIPAL.VISITAS_GUIADAS,
ACTIVIDAD_PRINCIPAL.ALOJAMIENTO,
ACTIVIDAD_PRINCIPAL.TURISMO,
],
[ACTIVIDAD_CENTRAL.COMERCIO]: [ACTIVIDAD_PRINCIPAL.COMERCIO],
};
// Map: Main Productive Activity -> Productive Activity (The long list)
export const ACTIVIDAD_PRODUCTIVA_MAP: Record<string, string[]> = {
[ACTIVIDAD_PRINCIPAL.AGRICULTURA]: [
'SIEMBRA DE MAIZ',
'SIEMBRA DE AJI',
'SIEMBRA DE CAFÉ',
'SIEMBRA DE PLATANO',
'SIEMBRA DE CAMBUR',
'SIEMBRA DE AGUACATE',
'SIEMBRA DE FRUTAS',
'SIEMBRA DE HORTALIZAS',
'SIEMBRA DE TOMATE',
'SIEMBRA DE CACAO',
'SIEMBRA DE PIMENTON',
'SIEMBRA DE YUCA',
'SIEMBRA DE CAÑA DE AZUCAR',
'SIEMBRA DE GRANOS (CARAOTAS, FRIJOLES)',
'SIEMBRA DE ARROZ',
'SIEMBRA DE CEREALES (CEBADA, LINAZA, SOYA)',
'ELABORACION DE BIO-INSUMO (ABONO ORGANICO)',
],
[ACTIVIDAD_PRINCIPAL.CRIA]: [
'BOVINO',
'PORCINO',
'CAPRINO',
'CUNICULTURA',
'AVICOLA',
'PISCICULA',
],
[ACTIVIDAD_PRINCIPAL.PATIOS_PRODUCTIVOS]: ['SIEMBRA Y CRIA'],
[ACTIVIDAD_PRINCIPAL.TRANSFORMACION_MATERIA]: [
'ELABORACION DE PRODUCTOS QUIMICOS (LIMPIEZA E HIGIENE PERSONAL)',
'PANADERIAS',
'RESPOSTERIA',
'ELABORACION DE HARINAS PRECOCIDA',
'PLANTA ABA (ELABORACION DE ALIMENTOS BALANCEADOS PARA ANIMALES)',
'ELABORACION DE PRODUCTOS DERIVADO DE LA LECHE (VACA, CABRA, BUFFALA)',
'EMPAQUETADORAS DE GRANOS Y POLVOS',
'ELABORACION DE ACEITE COMESTIBLE',
'FABRICA DE HIELO',
'ELABORACION DE PAPELON',
'TORREFACTORA DE CÁFE',
'ESPULPADORA DE TOMATES Y FRUTAS',
'ARTESANIAS',
],
[ACTIVIDAD_PRINCIPAL.TEXTIL]: [
'ELABORACION DE UNIFORME ESCOLARES Y PRENDA DE VESTIR',
'ELABORACION DE PRENDAS INTIMAS',
'ELABORACION DE LENCERIA',
'SUBLIMACION DE TEJIDOS',
'ELABORACION DE CALZADOS',
],
[ACTIVIDAD_PRINCIPAL.CONSTRUCCION]: [
'BLOQUERAS',
'PLANTA PREMEZCLADORA DE CEMENTO',
'CARPINTERIAS',
'HERRERIAS',
],
[ACTIVIDAD_PRINCIPAL.BIENES_SERVICIOS]: [
'MERCADOS COMUNALES',
'CENTROS DE ACOPIOS Y DISTRIBUCION',
'UNIDAD DE SUMINISTRO',
'MATADERO (SALA DE MATANZA DE ANIMALES)',
'PELUQUERIA',
'BARBERIA',
'AGENCIAS DE FESTEJOS',
'LAVANDERIAS',
'REPARACION DE CALZADOS',
'TALLER DE MECANICA',
'TRANSPORTES',
],
[ACTIVIDAD_PRINCIPAL.VISITAS_GUIADAS]: ['RUTAS TURISTICAS'],
[ACTIVIDAD_PRINCIPAL.ALOJAMIENTO]: ['POSADAS', 'HOTELES'],
[ACTIVIDAD_PRINCIPAL.TURISMO]: ['AGENCIAS DE VIAJES'],
[ACTIVIDAD_PRINCIPAL.COMERCIO]: [
'VENTA DE VIVERES',
'VENTAS DE PRENDAS DE VESTIR',
'VENTA DE PRODUCTOS QUIMICOS Y DERIVADOS',
'BODEGAS COMUNALES',
'FRIGORIFICOS Y CARNICOS',
],
};

View File

@@ -1,45 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CreateUser, UpdateUser } from "../schemas/users";
import { updateUserAction, createUserAction, deleteUserAction, updateProfileAction } from "../actions/actions";
// Create mutation
export function useCreateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: CreateUser) => createUserAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
// onError: (e) => console.error('Error:', e),
})
return mutation
}
// Update mutation
export function useUpdateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateUser) => updateUserAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
onError: (e) => console.error('Error:', e)
})
return mutation;
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateUser) => updateProfileAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
// onError: (e) => console.error('Error:', e)
})
return mutation;
}
// Delete mutation
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteUserAction(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
onError: (e) => console.error('Error:', e)
})
}

View File

@@ -1,14 +1,29 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { TrainingSchema } from "../schemas/training";
import { createTrainingAction, updateTrainingAction, deleteTrainingAction } from "../actions/training-actions";
import { useSafeQuery } from '@/hooks/use-safe-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createTrainingAction,
deleteTrainingAction,
getTrainingAction,
getTrainingByIdAction,
updateTrainingAction,
} from '../actions/training-actions';
import { TrainingSchema } from '../schemas/training';
export function useTrainingQuery(params = {}) {
return useSafeQuery(['training', params], () => getTrainingAction(params));
}
export function useTrainingByIdQuery(id: number) {
return useSafeQuery(['training', id], () => getTrainingByIdAction(id));
}
export function useCreateTraining() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: TrainingSchema) => createTrainingAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
})
return mutation
});
return mutation;
}
export function useUpdateTraining() {
@@ -16,7 +31,7 @@ export function useUpdateTraining() {
const mutation = useMutation({
mutationFn: (data: TrainingSchema) => updateTrainingAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
})
});
return mutation;
}
@@ -25,5 +40,5 @@ export function useDeleteTraining() {
return useMutation({
mutationFn: (id: number) => deleteTrainingAction(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
})
});
}

View File

@@ -1,13 +1,17 @@
import { z } from 'zod';
export const statisticsItemSchema = z.object({
name: z.string(),
name: z
.string()
.nullable()
.transform((val) => val || 'Sin Información'),
value: z.number(),
});
export const trainingStatisticsSchema = z.object({
totalOsps: z.number(),
totalProducers: z.number(),
totalProducts: z.number(),
statusDistribution: z.array(statisticsItemSchema),
activityDistribution: z.array(statisticsItemSchema),
typeDistribution: z.array(statisticsItemSchema),

View File

@@ -1,48 +1,322 @@
import { z } from 'zod';
// 1. Definimos el esquema de un item individual de la lista de productos
// Basado en los campos que usaste en ProductActivityList
const productItemSchema = z.object({
description: z.string().optional().nullable(),
dailyCount: z.coerce.string().or(z.number()).optional().nullable(),
weeklyCount: z.coerce.string().or(z.number()).optional().nullable(),
monthlyCount: z.coerce.string().or(z.number()).optional().nullable(),
});
const productionItemSchema = z.object({
supplyType: z.string().optional().nullable(),
quantity: z.coerce.string().or(z.number()).optional().nullable(),
unit: z.string().min(1, { message: 'Unidad es requerida' }).nullable(),
});
const equipmentItemSchema = z.object({
machine: z.string().nullable(),
quantity: z.coerce.string().or(z.number()).optional().nullable(),
});
export const trainingSchema = z.object({
//Datos de la visita
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(),
coorFullName: z
.string()
.min(1, { message: 'Nombre del coordinador es requerido' }),
coorPhone: z.string().refine((val) => /^(04|02)\d{9}$/.test(val), {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
visitDate: z
.string()
.min(1, { message: 'Fecha y hora de visita es requerida' }),
//Datos de la organización socioproductiva (OSP)
ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
ecoSector: z.string({ message: 'Sector Económico es requerido' }),
productiveSector: z.string({ message: 'Sector Productivo es requerido' }),
centralProductiveActivity: z.string({
message: 'Actividad Central Productiva es requerido',
}),
mainProductiveActivity: z.string({
message: 'Actividad Productiva Principal es requerida',
}),
productiveActivity: z.string({
message: 'Actividad Productiva es requerida',
}),
ospRif: z.string().optional().or(z.literal('')).nullable(),
ospName: z.string().optional().or(z.literal('')).nullable(),
companyConstitutionYear: z.coerce
.number()
.min(1900, { message: 'Año inválido' })
.nullable(),
currentStatus: z
.string()
.min(1, { message: 'Estatus actual es requerido' })
.default('ACTIVA'),
infrastructureMt2: z.string({ message: 'Infraestructura es requerida' }),
hasTransport: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.nullable()
.default(false),
structureType: z.string({ message: 'Tipo de estructura es requerido' }),
isOpenSpace: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.nullable()
.default(false),
paralysisReason: z.string().optional().nullable(),
//Datos del Equipamiento
equipmentList: z.array(equipmentItemSchema).optional().default([]),
//Datos de Producción
productionList: z.array(productionItemSchema).optional().default([]),
// Datos de Actividad Productiva
productList: z.array(productItemSchema).optional().default([]),
// Distribución y Exportación
internalDistributionZone: z
.string()
.min(1, { message: 'Zona de distribución es requerida' }),
isExporting: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.default(false),
externalCountry: z.string().optional().nullable(),
externalCity: z.string().optional().nullable(),
externalDescription: z.string().optional().nullable(),
externalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
externalUnit: z.string().optional().nullable(),
// Mano de obra
womenCount: z.coerce
.number()
.min(0, { message: 'Cantidad de mujeres es requerida' }),
menCount: z.coerce
.number()
.min(0, { message: 'Cantidad de hombres es requerida' }),
//Detalles de la ubicación
ospAddress: z
.string()
.min(1, { message: 'Dirección de la OSP es requerida' }),
ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
communeName: z
.string()
.min(1, { message: 'Nombre de la comuna es requerida' }),
siturCodeCommune: z
.string()
.min(1, { message: 'Código SITUR de la comuna es requerida' }),
communeRif: z.string().min(1, { message: 'Rif de la comuna es requerida' }),
communeSpokespersonName: z
.string()
.min(1, { message: 'Nombre del vocero de la comuna es requerido' }),
communeSpokespersonPhone: z
.string()
.optional()
.or(z.literal(''))
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
communeEmail: z
.string()
.email({ message: 'Correo electrónico de la Comuna inválido' })
.optional()
.or(z.literal(''))
.nullable(),
communalCouncil: z
.string()
.min(1, { message: 'Consejo Comunal es requerido' }),
siturCodeCommunalCouncil: z
.string()
.min(1, { message: 'Código SITUR del Consejo Comunal es requerido' }),
communalCouncilRif: z
.string()
.min(1, { message: 'Rif del Consejo Comunal es requerido' }),
communalCouncilSpokespersonName: z
.string()
.min(1, { message: 'Nombre del vocero es requerido' }),
communalCouncilSpokespersonPhone: z
.string()
.optional()
.or(z.literal(''))
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
communalCouncilEmail: z
.string()
.email({ message: 'Correo electrónico del Consejo Comunal inválido' })
.optional()
.or(z.literal(''))
.nullable(),
//Datos del Responsable OSP
ospResponsibleCedula: z
.string()
.min(1, { message: 'Cédula del responsable es requerida' }),
ospResponsibleFullname: z
.string()
.min(1, { message: 'Nombre del responsable es requerido' }),
ospResponsibleRif: z.string().optional().nullable(),
civilState: z.string().optional().nullable(),
ospResponsiblePhone: z
.string()
.min(1, { message: 'Teléfono del responsable es requerido' })
.regex(/^(04|02)\d{9}$/, {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
ospResponsibleEmail: z
.string()
.email({ message: 'Correo electrónico inválido' })
.optional()
.or(z.literal(''))
.nullable(),
familyBurden: z.coerce.number().optional(),
numberOfChildren: z.coerce.number().optional(),
//Datos adicionales
generalObservations: z.string().optional().nullable(),
//IMAGENES
files: z.any().optional(),
//no se envia la backend al crear ni editar el formulario
state: z.number({ message: 'El estado es requerido' }).nullable(),
municipality: z.number({ message: 'Municipio es requerido' }).nullable(),
parish: z.number({ message: 'Parroquia es requerido' }).nullable(),
coorState: z.number().optional().nullable(),
coorMunicipality: z.number().optional().nullable(),
coorParish: z.number().optional().nullable(),
photo1: z.string().optional().nullable(),
photo2: z.string().optional().nullable(),
photo3: z.string().optional().nullable(),
createdBy: z.number().optional().nullable(),
updatedBy: z.number().optional().nullable(),
created_at: z.string().optional().nullable(),
updated_at: z.string().optional().nullable(),
});
export type TrainingSchema = z.infer<typeof trainingSchema>;
export const getTrainingSchema = z.object({
//Datos de la visita
id: z.number().optional(),
coorFullName: z.string(),
coorPhone: z.string(),
visitDate: z.string(),
//Datos de la organización socioproductiva (OSP)
ospType: z.string(),
ecoSector: z.string(),
productiveSector: z.string(),
centralProductiveActivity: z.string(),
mainProductiveActivity: z.string(),
productiveActivity: z.string(),
ospRif: z.string().optional().or(z.literal('')).nullable(),
ospName: z.string().optional().or(z.literal('')).nullable(),
companyConstitutionYear: z.coerce.number(),
currentStatus: z.string(),
infrastructureMt2: z.string(),
hasTransport: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.nullable()
.default(false),
structureType: z.string(),
isOpenSpace: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.nullable()
.default(false),
paralysisReason: z.string().optional().nullable(),
//Datos del Equipamiento
equipmentList: z.array(equipmentItemSchema).optional().default([]),
//Datos de Producción
productionList: z.array(productionItemSchema).optional().default([]),
// Datos de Actividad Productiva
productList: z.array(productItemSchema).optional().default([]),
// Distribución y Exportación
internalDistributionZone: z.string(),
isExporting: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.default(false),
externalCountry: z.string().optional().nullable(),
externalCity: z.string().optional().nullable(),
externalDescription: z.string().optional().nullable(),
externalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
externalUnit: z.string().optional().nullable(),
// Mano de obra
womenCount: z.coerce.number(),
menCount: z.coerce.number(),
//Detalles de la ubicación
ospAddress: z.string(),
ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
communeName: z.string(),
siturCodeCommune: z.string(),
communeRif: z.string().or(z.literal('')).nullable(),
communeSpokespersonName: z.string().or(z.literal('')).nullable(),
communeSpokespersonPhone: z.string(),
communeEmail: z.string().optional().or(z.literal('')).nullable(),
communalCouncil: z.string(),
siturCodeCommunalCouncil: z.string(),
communalCouncilRif: z.string().optional(),
communalCouncilSpokespersonName: z.string(),
communalCouncilSpokespersonPhone: z.string(),
communalCouncilEmail: z.string(),
//Datos del Responsable OSP
ospResponsibleCedula: z.string(),
ospResponsibleFullname: z.string(),
ospResponsibleRif: z.string().optional().nullable(),
civilState: z.string().optional().nullable(),
ospResponsiblePhone: z.string(),
ospResponsibleEmail: z.string(),
familyBurden: z.coerce.number().optional(),
numberOfChildren: z.coerce.number().optional(),
//Datos adicionales
generalObservations: z.string().optional().nullable(),
//no se envia la backend al crear ni editar el formulario
state: z.number().nullable(),
municipality: z.number().nullable(),
parish: z.number().nullable(),
coorState: z.number().optional().nullable(),
coorMunicipality: z.number().optional().nullable(),
coorParish: z.number().optional().nullable(),
photo1: z.string().optional().nullable(),
photo2: z.string().optional().nullable(),
photo3: z.string().optional().nullable(),
createdBy: z.number().optional().nullable(),
updatedBy: z.number().optional().nullable(),
created_at: z.string().optional().nullable(),
updated_at: z.string().optional().nullable(),
});
export const trainingApiResponseSchema = z.object({
message: z.string(),
data: z.array(trainingSchema),
data: z.array(getTrainingSchema),
meta: z.object({
page: z.number(),
limit: z.number(),

View File

@@ -1,23 +1,22 @@
'use server';
import { safeFetchApi } from '@/lib/fetch.api';
import {
surveysApiResponseSchema,
CreateUser,
surveysApiResponseSchema,
UpdateUser,
UsersMutate,
UpdateUser
} from '../schemas/users';
import { auth } from '@/lib/auth';
export const getProfileAction = async () => {
const session = await auth()
const id = session?.user?.id
const session = await auth();
const id = session?.user?.id;
const [error, response] = await safeFetchApi(
UsersMutate,
`/users/${id}`,
'GET'
'GET',
);
if (error) throw new Error(error.message);
return response;
@@ -33,7 +32,6 @@ export const updateProfileAction = async (payload: UpdateUser) => {
payloadWithoutId,
);
console.log(payload);
if (error) {
if (error.message === 'Email already exists') {
throw new Error('Ese correo ya está en uso');
@@ -51,7 +49,6 @@ export const getUsersAction = async (params: {
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
@@ -83,7 +80,7 @@ export const getUsersAction = async (params: {
previousPage: null,
},
};
}
};
export const createUserAction = async (payload: CreateUser) => {
const { id, confirmPassword, ...payloadWithoutId } = payload;
@@ -130,19 +127,14 @@ export const updateUserAction = async (payload: UpdateUser) => {
} catch (error) {
console.error(error);
}
}
};
export const deleteUserAction = async (id: Number) => {
const [error] = await safeFetchApi(
UsersMutate,
`/users/${id}`,
'DELETE'
)
const [error] = await safeFetchApi(UsersMutate, `/users/${id}`, 'DELETE');
console.log(error);
// if (error) throw new Error(error.message || 'Error al eliminar el usuario')
return true;
}
};

View File

@@ -19,7 +19,7 @@ import {
SelectValue,
} from '@repo/shadcn/select';
import { useForm } from 'react-hook-form';
import { useCreateUser } from "../../hooks/use-mutation-users";
import { useCreateUser } from '../../hooks/use-mutation-users';
import { CreateUser, createUser } from '../../schemas/users';
const ROLES = {
@@ -29,8 +29,9 @@ const ROLES = {
4: 'Gerente',
5: 'Usuario',
6: 'Productor',
7: 'Organización'
}
7: 'Organización',
8: 'Coordinadores',
};
interface CreateUserFormProps {
onSuccess?: () => void;
@@ -60,7 +61,7 @@ export function CreateUserForm({
id: defaultValues?.id,
phone: defaultValues?.phone || '',
role: undefined,
}
};
const form = useForm<CreateUser>({
resolver: zodResolver(createUser),
@@ -69,8 +70,6 @@ export function CreateUserForm({
});
const onSubmit = async (formData: CreateUser) => {
console.log(formData);
saveAccountingAccounts(formData, {
onSuccess: () => {
form.reset();
@@ -143,7 +142,7 @@ export function CreateUserForm({
<FormItem>
<FormLabel>Teléfono</FormLabel>
<FormControl>
<Input {...field} value={field.value?.toString() ?? ''}/>
<Input {...field} value={field.value?.toString() ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
@@ -157,7 +156,7 @@ export function CreateUserForm({
<FormItem>
<FormLabel>Contraseña</FormLabel>
<FormControl>
<Input type="password" {...field}/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -166,12 +165,12 @@ export function CreateUserForm({
<FormField
control={form.control}
name='confirmPassword'
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmar Contraseña</FormLabel>
<FormControl>
<Input type="password" {...field}/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -184,7 +183,9 @@ export function CreateUserForm({
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Rol</FormLabel>
<Select onValueChange={(value) => field.onChange(Number(value))}>
<Select
onValueChange={(value) => field.onChange(Number(value))}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona un rol" />

View File

@@ -1,5 +1,7 @@
'use client';
import { useUpdateUser } from '@/feactures/users/hooks/use-mutation-users';
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@repo/shadcn/button';
import {
@@ -19,8 +21,6 @@ import {
SelectValue,
} from '@repo/shadcn/select';
import { useForm } from 'react-hook-form';
import { useUpdateUser } from "@/feactures/users/hooks/use-mutation-users";
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
const ROLES = {
// 1: 'Superadmin',
@@ -29,8 +29,9 @@ const ROLES = {
4: 'Gerente',
5: 'Usuario',
6: 'Productor',
7: 'Organización'
}
7: 'Organización',
8: 'Coordinadores',
};
interface UserFormProps {
onSuccess?: () => void;
@@ -57,8 +58,8 @@ export function UpdateUserForm({
id: defaultValues?.id,
phone: defaultValues?.phone || '',
role: undefined,
isActive: defaultValues?.isActive
}
isActive: defaultValues?.isActive,
};
// console.log(defaultValues);
@@ -69,8 +70,7 @@ export function UpdateUserForm({
});
const onSubmit = async (data: UpdateUser) => {
const formData = data
const formData = data;
saveAccountingAccounts(formData, {
onSuccess: () => {
@@ -144,7 +144,7 @@ export function UpdateUserForm({
<FormItem>
<FormLabel>Teléfono</FormLabel>
<FormControl>
<Input {...field} value={field.value?.toString() ?? ''}/>
<Input {...field} value={field.value?.toString() ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
@@ -153,12 +153,12 @@ export function UpdateUserForm({
<FormField
control={form.control}
name='password'
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Nueva Contraseña</FormLabel>
<FormControl>
<Input type="password" {...field}/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -171,7 +171,9 @@ export function UpdateUserForm({
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Rol</FormLabel>
<Select onValueChange={(value) => field.onChange(Number(value))}>
<Select
onValueChange={(value) => field.onChange(Number(value))}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona un rol" />
@@ -196,7 +198,10 @@ export function UpdateUserForm({
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Estatus</FormLabel>
<Select defaultValue={String(field.value)} onValueChange={(value) => field.onChange(Boolean(value))}>
<Select
defaultValue={String(field.value)}
onValueChange={(value) => field.onChange(Boolean(value))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleccione un estatus" />
</SelectTrigger>

View File

@@ -5,12 +5,24 @@ import { Edit2 } from 'lucide-react';
import { useState } from 'react';
import { AccountPlanModal } from './modal-profile';
const ROLE_TRANSLATIONS: Record<string, string> = {
superadmin: 'Superadmin',
admin: 'Administrador',
autoridad: 'Autoridad',
manager: 'Gerente',
user: 'Usuario',
producers: 'Productor',
organization: 'Organización',
coordinators: 'Coordinador',
};
export function Profile() {
const [open, setOpen] = useState(false);
const { data } = useUserByProfile();
// console.log("🎯 data:", data);
const userRole = data?.data.role as string;
const translatedRole = ROLE_TRANSLATIONS[userRole] || userRole || 'Sin Rol';
return (
<div>
@@ -18,58 +30,60 @@ export function Profile() {
<Edit2 className="mr-2 h-4 w-4" /> Editar Perfil
</Button>
<AccountPlanModal open={open} onOpenChange={setOpen} defaultValues={data?.data}/>
<AccountPlanModal
open={open}
onOpenChange={setOpen}
defaultValues={data?.data}
/>
<h2 className='mt-3 mb-1'>Datos del usuario</h2>
<h2 className="mt-3 mb-1">Datos del usuario</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 ">
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Usuario:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Usuario:</p>
<p>{data?.data.username || 'Sin Nombre de Usuario'}</p>
</section>
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Rol:</p>
<p>{data?.data.role || 'Sin Rol'}</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Rol:</p>
<p>{translatedRole}</p>
</section>
</div>
<h2 className='mt-3 mb-1'>Información personal</h2>
<h2 className="mt-3 mb-1">Información personal</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Nombre completo:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Nombre completo:</p>
<p>{data?.data.fullname || 'Sin nombre y apellido'}</p>
</section>
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Correo:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Correo:</p>
<p>{data?.data.email || 'Sin correo'}</p>
</section>
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Teléfono:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Teléfono:</p>
<p>{data?.data.phone || 'Sin teléfono'}</p>
</section>
</div>
<h2 className='mt-3 mb-1'>Información de ubicación</h2>
<h2 className="mt-3 mb-1">Información de ubicación</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Estado:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Estado:</p>
<p>{data?.data.state || 'Sin Estado'}</p>
</section>
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Municipio:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Municipio:</p>
<p>{data?.data.municipality || 'Sin Municipio'}</p>
</section>
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
<p className='font-bold text-lg'>Parroquia:</p>
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
<p className="font-bold text-lg">Parroquia:</p>
<p>{data?.data.parish || 'Sin Parroquia'}</p>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { resfreshTokenAction } from '@/feactures/auth/actions/refresh-token-action';
import { auth } from '@/lib/auth';
import { cookies } from 'next/headers';
import { cache } from 'react';
export const getValidAccessToken = cache(async () => {
const session = await auth();
if (!session?.access_token) {
// console.log('No hay Access Token');
return null
}
// console.log('Si hay Access Token');
const now = Math.floor(Date.now() / 1000);
// Restamos 10s para tener margen de seguridad
const isValid = (session.access_expire_in as number) - 10 > now;
// A. Si es válido, lo retornamos directo
if (isValid) return session.access_token;
// console.log('Access Token Expiró');
// B. Si expiró, buscamos la cookie
const cookieStore = cookies();
const cookie = await cookieStore
const refreshToken = cookie.get('refresh_token')?.value;
const teaToken = cookie.get('tea_token')?.value;
if (!refreshToken) {
// console.log('No hay Refresh Token');
// Si no hay refres pero si access token pero ya expiro borrar la cookie para forzar cierre de session
(await cookieStore).delete('authjs.session-token');// comentar si por algun motivo da error
return null
} // No hay refresh token, fin del juego
// console.log('Si hay Refresh Token');
if (teaToken) {
return teaToken
}
// C. Intentamos refrescar
const newTokens = await resfreshTokenAction({ refreshToken });
if (!newTokens) {
// console.log('No hay token nuevo');
// Si falla el refresh (token revocado o expirado), borramos cookies
(await cookieStore).delete('refresh_token');
(await cookieStore).delete('authjs.session-token');// comentar si por algun motivo da error
return null;
}
// console.log('Si hay token nuevo');
// console.log('Guardamos refresh');
// D. Guardamos el nuevo refresh token en cookie y retornamos el access token
(await cookieStore).set('refresh_token', newTokens.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60,
});
// console.log('guardamo tea');
(await cookieStore).set('tea_token', newTokens.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60,
});
return newTokens.access_token;
});

View File

@@ -1,11 +1,10 @@
// lib/auth.config.ts
import { SignInAction } from '@/feactures/auth/actions/login-action';
import { resfreshTokenAction } from '@/feactures/auth/actions/refresh-token-action';
import { logoutAction } from '@/feactures/auth/actions/logout-action';
import { CredentialsSignin, NextAuthConfig, Session, User } from 'next-auth';
import { DefaultJWT } from 'next-auth/jwt';
import { DefaultJWT, JWT } from 'next-auth/jwt';
import CredentialProvider from 'next-auth/providers/credentials';
// Define los tipos para tus respuestas de SignInAction
interface SignInSuccessResponse {
message: string;
@@ -58,8 +57,10 @@ const authConfig: NextAuthConfig = {
// **NUEVO: Manejar el caso `null` primero**
if (response === null) {
console.error("SignInAction returned null, indicating a potential issue before API call or generic error.");
throw new CredentialsSignin("Error de inicio de sesión inesperado.");
console.error(
'SignInAction returned null, indicating a potential issue before API call or generic error.',
);
throw new CredentialsSignin('Error de inicio de sesión inesperado.');
}
// Tipo Guarda: Verificar la respuesta de error
@@ -70,15 +71,19 @@ const authConfig: NextAuthConfig = {
response.type === 'UNKNOWN_ERROR') // Incluye todos los tipos de error posibles
) {
// Si es un error, lánzalo. Este camino termina aquí.
throw new CredentialsSignin("Error en la API:" + response.message);
throw new CredentialsSignin('Error en la API:' + response.message);
}
if (!('user' in response)) {
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null,
// no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
// Es un caso de respuesta inesperada del API.
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.");
throw new CredentialsSignin("Error en el formato de la respuesta del servidor.");
console.error(
"Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.",
);
throw new CredentialsSignin(
'Error en el formato de la respuesta del servidor.',
);
}
return {
@@ -89,11 +94,7 @@ const authConfig: NextAuthConfig = {
role: response?.user.rol ?? [], // Add role array
access_token: response?.tokens.access_token ?? '',
access_expire_in: response?.tokens.access_expire_in ?? 0,
refresh_token: response?.tokens.refresh_token ?? '',
refresh_expire_in: response?.tokens.refresh_expire_in ?? 0,
};
},
}),
],
@@ -101,11 +102,7 @@ const authConfig: NextAuthConfig = {
signIn: '/', //sigin page
},
callbacks: {
async jwt({ token, user }:{
user: User
token: any
}) {
async jwt({ token, user }: { user: User; token: any }) {
// 1. Manejar el inicio de sesión inicial
// El `user` solo se proporciona en el primer inicio de sesión.
if (user) {
@@ -117,68 +114,14 @@ const authConfig: NextAuthConfig = {
role: user.role,
access_token: user.access_token,
access_expire_in: user.access_expire_in,
refresh_token: user.refresh_token,
refresh_expire_in: user.refresh_expire_in
}
// return token;
};
}
// 2. Si no es un nuevo login, verificar la expiración del token
const now = Math.floor(Date.now() / 1000); // Usar Math.floor para un número entero
// Verificar si el token de acceso aún es válido
if (now < (token.access_expire_in as number)) {
return token; // Si no ha expirado, no hacer nada y devolver el token actual
}
// console.log("Now Access Expire:",token.access_expire_in);
// 3. Si el token de acceso ha expirado, verificar el refresh token
// console.log("Access token ha expirado. Verificando refresh token...");
if (now > (token.refresh_expire_in as number)) {
// console.log("Refresh token ha expirado. Forzando logout.");
return null; // Forzar el logout al devolver null
}
// console.log("token:", token.refresh_token);
// 4. Si el token de acceso ha expirado pero el refresh token es válido, renovar
// console.log("Renovando token de acceso...");
try {
const refresh_token = { token: token.refresh_token as string, user_id: Number(token.id) as number}
console.log(refresh_token);
const res = await resfreshTokenAction(refresh_token);
if (!res || !res.tokens) {
throw new Error('Fallo en la respuesta de la API de refresco.');
}
// console.log("Old Access Expire:", token.access_expire_in);
// console.log("New Access Expire:", res.tokens.access_expire_in);
// console.log("token:", token.refresh_token);
// Actualizar el token directamente con los nuevos valores
token.access_token = res.tokens.access_token;
token.access_expire_in = res.tokens.access_expire_in;
token.refresh_token = res.tokens.refresh_token;
token.refresh_expire_in = res.tokens.refresh_expire_in;
return token;
} catch (error) {
console.error("Error al renovar el token: ", error);
return null; // Fallo al renovar, forzar logout
}
},
async session({ session, token }: { session: Session; token: any }) {
async session({ session, token }: { session: Session; token: DefaultJWT }) {
session.access_token = token.access_token as string;
session.access_expire_in = token.access_expire_in as number;
session.refresh_token = token.refresh_token as string;
session.refresh_expire_in = token.refresh_expire_in as number;
session.user = {
id: token.id as number,
username: token.username as string,
@@ -189,7 +132,18 @@ const authConfig: NextAuthConfig = {
return session;
},
},
events: {
async signOut(message) {
// 1. verificamos que venga token (puede no venir con algunos providers)
const token = (message as { token?: JWT }).token;
if (!token?.access_token) return;
try {
await logoutAction(String(token?.id));
} catch {
/* silencioso para que next-auth siempre cierre */
}
},
},
} satisfies NextAuthConfig;
export default authConfig;

View File

@@ -1,6 +1,6 @@
'use server';
import { env } from '@/lib/env';
import axios from 'axios';
import axios, { InternalAxiosRequestConfig } from 'axios';
import { z } from 'zod';
// Crear instancia de Axios con la URL base validada
@@ -10,33 +10,21 @@ const fetchApi = axios.create({
// Interceptor para incluir el token automáticamente en las peticiones
// ESTE INTERCEPTOR ESTÁ BIEN PARA EL RESTO DE LAS PETICIONES AUTENTICADAS
fetchApi.interceptors.request.use(async (config: any) => {
fetchApi.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
try {
// console.log("Solicitando autenticación...");
const { auth } = await import('@/lib/auth'); // Importación dinámica
const session = await auth();
const token = session?.access_token;
const { getValidAccessToken } = await import('@/lib/auth-token');
const token = await getValidAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
config.headers.set('Authorization', `Bearer ${token}`);
}
// **Importante:** Si el body es FormData, elimina el Content-Type para que Axios lo configure automáticamente.
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
} else {
config.headers['Content-Type'] = 'application/json';
} catch (err) {
console.error('Error getting auth token:', err);
}
return config;
} catch (error) {
console.error('Error al obtener el token de autenticación para el interceptor:', error);
// IMPORTANTE: Si ocurre un error aquí, es mejor rechazar la promesa
// para que la solicitud no se envíe sin autorización.
return Promise.reject(error);
}
});
},
);
// safeFetchApi sigue siendo útil para el resto de las llamadas que requieren autenticación
export const safeFetchApi = async <T extends z.ZodSchema<any>>(

View File

@@ -1,99 +0,0 @@
'use server';
import { env } from '@/lib/env'; // Importamos la configuración de entorno validada
import axios from 'axios';
import { z } from 'zod';
// Crear instancia de Axios con la URL base validada
const fetchApi = axios.create({
baseURL: env.API_URL, // Aquí usamos env.API_URL en vez de process.env.BACKEND_URL
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para incluir el token automáticamente en las peticiones
fetchApi.interceptors.request.use(async (config: any) => {
try {
// Importación dinámica para evitar la referencia circular
const { auth } = await import('@/lib/auth');
const session = await auth();
const token = session?.access_token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} catch (error) {
console.error('Error getting auth token:', error);
}
return config;
});
/**
* Función para hacer peticiones con validación de respuesta
* @param schema - Esquema de Zod para validar la respuesta
* @param url - Endpoint a consultar
* @param config - Configuración opcional de Axios
* @returns [error, data] -> Devuelve el error como objeto estructurado si hay fallo, o los datos validados
*/
export const safeFetchApi = async <T extends z.ZodSchema<any>>(
schema: T,
url: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
body?: any,
): Promise<
[{ type: string; message: string; details?: any } | null, z.infer<T> | null]
> => {
try {
const response = await fetchApi({
method,
url,
data: body,
});
const parsed = schema.safeParse(response.data);
if (!parsed.success) {
console.error('Validation Error Details:', {
errors: parsed.error.errors,
receivedData: response.data,
expectedSchema: schema,
data: response.data.data,
});
// console.error(parsed.error.errors)
return [
{
type: 'VALIDATION_ERROR',
message: 'Validation error',
details: parsed.error.errors,
},
null,
];
}
return [null, parsed.data];
} catch (error: any) {
const errorDetails = {
status: error.response?.status,
statusText: error.response?.statusText,
message: error.message,
url: error.config?.url,
method: error.config?.method,
requestData: error.config?.data,
responseData: error.response?.data,
headers: error.config?.headers,
};
// console.log(error)
return [
{
type: 'API_ERROR',
message: error.response?.data?.message || 'Unknown API error',
details: errorDetails,
},
null,
];
}
};
export { fetchApi };

View File

@@ -31,14 +31,14 @@
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.475.0",
"next": "^15.1.6",
"next": "^15.5.9",
"next-auth": "5.0.0-beta.25",
"next-safe-action": "^7.10.2",
"next-themes": "^0.4.4",
"nextjs-toploader": "^3.7.15",
"nuqs": "^2.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.0.3",
"react-dom": "^19.0.3",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.3",
"sonner": "^2.0.1",

Some files were not shown because too many files have changed in this diff Show More