46 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
24bc0476e6 form guarda y estadisticas 2025-12-09 17:56:48 -04:00
01c7bd149d Campos faltantes 2025-12-04 19:02:02 -04:00
d3b3fa5e85 select ubicacion 2025-12-02 15:19:57 -04:00
efa1726223 formulario de capacitacion 2025-12-01 18:23:18 -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
6f8a55b8fd refresh token arreglado 2025-10-06 10:31:20 -04:00
e2105ccbf5 cambios en el refresh token 2025-10-01 15:13:57 -04:00
d71c25f0ff Merge branch 'inventory' 2025-09-23 10:44:54 -04:00
08fa179276 ajustes responsive login y btn añadir de administracion 2025-07-02 10:10:33 -04:00
5cd663a653 cambio en la descripcion 2025-06-20 12:59:22 -04:00
5137c07c88 Se cambio caja de ahorro por fodemi 2025-06-20 12:58:09 -04:00
124 changed files with 44620 additions and 716 deletions

View File

@@ -17,3 +17,10 @@ DATABASE_URL="postgresql://postgres:local**@localhost:5432/caja_ahorro" #url con
MAIL_HOST=gmail MAIL_HOST=gmail
MAIL_USERNAME= MAIL_USERNAME=
MAIL_PASSWORD= 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) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Uploads
/uploads/training/*

View File

@@ -5,6 +5,13 @@
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": true,
"builder": "swc", "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", "drizzle-orm": "0.40.0",
"express": "5.1.0", "express": "5.1.0",
"joi": "17.13.3", "joi": "17.13.3",
"minio": "^8.0.6",
"moment": "2.30.1", "moment": "2.30.1",
"path-to-regexp": "8.2.0", "path-to-regexp": "8.2.0",
"pg": "8.13.3", "pg": "8.13.3",
"pino-pretty": "13.0.0", "pino-pretty": "13.0.0",
"reflect-metadata": "0.2.0", "reflect-metadata": "0.2.0",
"rxjs": "7.8.1" "rxjs": "7.8.1",
"sharp": "^0.34.5",
"xlsx-populate": "^1.21.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",

View File

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

View File

@@ -14,6 +14,12 @@ interface EnvVars {
MAIL_HOST: string; MAIL_HOST: string;
MAIL_USERNAME: string; MAIL_USERNAME: string;
MAIL_PASSWORD: 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 const envsSchema = joi
@@ -30,6 +36,12 @@ const envsSchema = joi
MAIL_HOST: joi.string(), MAIL_HOST: joi.string(),
MAIL_USERNAME: joi.string(), MAIL_USERNAME: joi.string(),
MAIL_PASSWORD: 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); .unknown(true);
@@ -54,4 +66,10 @@ export const envs = {
mail_host: envVars.MAIL_HOST, mail_host: envVars.MAIL_HOST,
mail_username: envVars.MAIL_USERNAME, mail_username: envVars.MAIL_USERNAME,
mail_password: envVars.MAIL_PASSWORD, 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', name: 'short',
ttl: 1000, // 1 sec ttl: 1000, // 1 sec
limit: 2, limit: 10,
}, },
{ {
name: 'medium', name: 'medium',
ttl: 10000, // 10 sec ttl: 10000, // 10 sec
limit: 4, limit: 30,
}, },
{ {
name: 'long', name: 'long',
ttl: 60000, // 1 min ttl: 60000, // 1 min
limit: 10, limit: 100,
}, },
], ],
errorMessage: 'Too many requests, please try again later.', 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,37 @@
CREATE TABLE "training_surveys" (
"id" serial PRIMARY KEY NOT NULL,
"firstname" text NOT NULL,
"lastname" text NOT NULL,
"visit_date" timestamp NOT NULL,
"productive_activity" text NOT NULL,
"financial_requirement_description" text NOT NULL,
"situr_code_commune" text NOT NULL,
"communal_council" text NOT NULL,
"situr_code_communal_council" text NOT NULL,
"osp_name" text NOT NULL,
"osp_address" text NOT NULL,
"osp_rif" text NOT NULL,
"osp_type" text NOT NULL,
"current_status" text NOT NULL,
"company_constitution_year" integer NOT NULL,
"producer_count" integer NOT NULL,
"product_description" text NOT NULL,
"installed_capacity" text NOT NULL,
"operational_capacity" text NOT NULL,
"osp_responsible_fullname" text NOT NULL,
"osp_responsible_cedula" text NOT NULL,
"osp_responsible_rif" text NOT NULL,
"osp_responsible_phone" text NOT NULL,
"civil_state" text NOT NULL,
"family_burden" integer NOT NULL,
"number_of_children" integer NOT NULL,
"general_observations" text NOT NULL,
"photo1" text NOT NULL,
"photo2" text NOT NULL,
"photo3" text NOT NULL,
"paralysis_reason" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE INDEX "training_surveys_index_00" ON "training_surveys" USING btree ("firstname");

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,132 @@
"when": 1754420096323, "when": 1754420096323,
"tag": "0007_curved_fantastic_four", "tag": "0007_curved_fantastic_four",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1764623430844,
"tag": "0008_plain_scream",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1764883378610,
"tag": "0009_eminent_ares",
"breakpoints": true
},
{
"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 { sql } from 'drizzle-orm';
import { authSchema } from './schemas'; import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../timestamps'; import { timestamps } from '../timestamps';
import { states, municipalities, parishes } from './general'; import { municipalities, parishes, states } from './general';
import { authSchema } from './schemas';
// Tabla de Usuarios sistema // Tabla de Usuarios sistema
export const users = authSchema.table( export const users = authSchema.table(
@@ -15,9 +14,15 @@ export const users = authSchema.table(
fullname: t.text('fullname').notNull(), fullname: t.text('fullname').notNull(),
phone: t.text('phone'), phone: t.text('phone'),
password: t.text('password').notNull(), password: t.text('password').notNull(),
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }), state: t
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }), .integer('state')
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }), .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 isTwoFactorEnabled: t
.boolean('is_two_factor_enabled') .boolean('is_two_factor_enabled')
.notNull() .notNull()
@@ -32,7 +37,6 @@ export const users = authSchema.table(
}), }),
); );
// Tabla de Roles // Tabla de Roles
export const roles = authSchema.table( export const roles = authSchema.table(
'roles', 'roles',
@@ -46,8 +50,6 @@ export const roles = authSchema.table(
}), }),
); );
//tabla User_roles //tabla User_roles
export const usersRole = authSchema.table( export const usersRole = authSchema.table(
'user_role', 'user_role',
@@ -88,7 +90,6 @@ LEFT JOIN
LEFT JOIN LEFT JOIN
auth.roles r ON ur.role_id = r.id`); auth.roles r ON ur.role_id = r.id`);
// Tabla de Sesiones // Tabla de Sesiones
export const sessions = authSchema.table( export const sessions = authSchema.table(
'sessions', 'sessions',
@@ -103,6 +104,9 @@ export const sessions = authSchema.table(
.notNull(), .notNull(),
sessionToken: t.text('session_token').notNull(), sessionToken: t.text('session_token').notNull(),
expiresAt: t.integer('expires_at').notNull(), expiresAt: t.integer('expires_at').notNull(),
previousSessionToken: t.varchar('previous_session_token'),
lastRotatedAt: t.timestamp('last_rotated_at'),
...timestamps, ...timestamps,
}, },
(sessions) => ({ (sessions) => ({
@@ -110,8 +114,6 @@ export const sessions = authSchema.table(
}), }),
); );
//tabla de tokens de verificación //tabla de tokens de verificación
export const verificationTokens = authSchema.table( export const verificationTokens = authSchema.table(
'verificationToken', 'verificationToken',

View File

@@ -1,8 +1,8 @@
import { sql } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core'; import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps'; import { timestamps } from '../timestamps';
import { users } from './auth'; import { users } from './auth';
import { municipalities, parishes, states } from './general';
// Tabla surveys // Tabla surveys
export const surveys = t.pgTable( export const surveys = t.pgTable(
@@ -18,9 +18,7 @@ export const surveys = t.pgTable(
...timestamps, ...timestamps,
}, },
(surveys) => ({ (surveys) => ({
surveysIndex: t surveysIndex: t.index('surveys_index_00').on(surveys.title),
.index('surveys_index_00')
.on(surveys.title),
}), }),
); );
@@ -44,7 +42,129 @@ export const answersSurveys = t.pgTable(
}), }),
); );
// Tabla training_surveys
export const trainingSurveys = t.pgTable(
'training_surveys',
{
// === 1. IDENTIFICADORES Y DATOS DE VISITA ===
id: t.serial('id').primaryKey(),
coorFullName: t.text('coor_full_name').notNull(),
visitDate: t.timestamp('visit_date').notNull(),
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(),
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'),
civilState: t.text('civil_state'),
ospResponsiblePhone: t.text('osp_responsible_phone').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.coorFullName),
}),
);
export const viewSurveys = t.pgView('v_surveys', { export const viewSurveys = t.pgView('v_surveys', {
surverId: t.integer('survey_id'), surverId: t.integer('survey_id'),
@@ -52,6 +172,7 @@ export const viewSurveys = t.pgView('v_surveys', {
description: t.text('description'), description: t.text('description'),
created_at: t.timestamp('created_at'), created_at: t.timestamp('created_at'),
closingDate: t.date('closing_date'), closingDate: t.date('closing_date'),
targetAudience: t.varchar('target_audience') 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`); .as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);

View File

@@ -1,24 +1,14 @@
// api/src/feacture/auth/auth.controller.ts
import { Public } from '@/common/decorators'; 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 { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto'; import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto'; import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { import { Body, Controller, HttpCode, Patch, Post } from '@nestjs/common';
Body,
Controller,
Get,
HttpCode,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) { }
@Public() @Public()
@HttpCode(200) @HttpCode(200)
@@ -38,6 +28,8 @@ export class AuthController {
return await this.authService.signIn(signInUserDto); return await this.authService.signIn(signInUserDto);
} }
@Public()
@HttpCode(200)
@Post('sign-out') @Post('sign-out')
//@RequirePermissions('auth:sign-out') //@RequirePermissions('auth:sign-out')
async signOut(@Body() signOutUserDto: SignOutUserDto) { async signOut(@Body() signOutUserDto: SignOutUserDto) {
@@ -51,26 +43,17 @@ export class AuthController {
// return { message: 'Password reset link sent to your email' }; // return { message: 'Password reset link sent to your email' };
// } // }
@UseGuards(JwtRefreshGuard) // @UseGuards(JwtRefreshGuard)
@Public() @Public()
@HttpCode(200) @HttpCode(200)
@Patch('refresh') @Patch('refresh')
//@RequirePermissions('auth:refresh-token') //@RequirePermissions('auth:refresh-token')
async refreshToken(@Req() req: Request,@Body() refreshTokenDto: RefreshTokenDto) { async refreshToken(@Body() refreshTokenDto: any) {
// console.log('REFRESCANDO');
// console.log("Pepeeeee"); // console.log(refreshTokenDto);
// console.log(req['user']); // console.log('-----------');
// console.log("refreshTokenDto",refreshTokenDto);
// console.log(typeof refreshTokenDto);
const data = await this.authService.refreshToken(refreshTokenDto,req['user'].sub); return await this.authService.refreshToken(refreshTokenDto);
// console.log("data",data);
if (!data) {
return null;
}
return {tokens: data}
} }
// @Public() // @Public()

View File

@@ -1,10 +1,11 @@
// auth.service
import { envs } from '@/common/config/envs'; import { envs } from '@/common/config/envs';
import { Env, validateString } from '@/common/utils'; import { Env, validateString } from '@/common/utils';
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto'; import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.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 { 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 { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface'; import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
import { import {
@@ -23,14 +24,14 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 crypto from 'crypto';
import { and, eq, or } from 'drizzle-orm'; import { eq, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index'; import * as schema from 'src/database/index';
import { sessions, users, roles, usersRole } from 'src/database/index'; import { roles, sessions, users, usersRole } from 'src/database/index';
import { Session } from './interfaces/session.interface'; import { Session } from './interfaces/session.interface';
import * as bcrypt from 'bcryptjs';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -39,7 +40,7 @@ export class AuthService {
private readonly config: ConfigService<Env>, private readonly config: ConfigService<Env>,
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>, @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
private readonly mailService: MailService, private readonly mailService: MailService,
) {} ) { }
//Decode Tokens //Decode Tokens
// Método para decodificar el token y obtener los datos completos // Método para decodificar el token y obtener los datos completos
@@ -80,33 +81,43 @@ export class AuthService {
//Generate Tokens //Generate Tokens
async generateTokens(user: User): Promise<AuthTokensInterface> { 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([ const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync( this.jwtService.signAsync(payload, {
{ secret: accessTokenSecret,
sub: user.id, expiresIn: accessTokenExp,
username: user.username, } as JwtSignOptions),
},
{ this.jwtService.signAsync(payload, {
secret: envs.access_token_secret, secret: refreshTokenSecret,
expiresIn: envs.access_token_expiration, expiresIn: refreshTokenExp,
}, } as JwtSignOptions),
),
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.refresh_token_secret,
expiresIn: envs.refresh_token_expiration,
},
),
]); ]);
return { return { access_token, refresh_token };
access_token,
refresh_token,
};
} }
//Generate OTP Code For Email Confirmation //Generate OTP Code For Email Confirmation
@@ -137,7 +148,8 @@ export class AuthService {
userId: parseInt(userId), userId: parseInt(userId),
expiresAt: sessionInput.expiresAt, 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'; return 'Session created successfully';
} }
@@ -196,7 +208,6 @@ export class AuthService {
//Sign In User Account //Sign In User Account
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> { async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
const user = await this.validateUser(dto); const user = await this.validateUser(dto);
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token); const decodeAccess = this.decodeToken(tokens.access_token);
@@ -261,115 +272,197 @@ export class AuthService {
} }
//Refresh User Access Token //Refresh User Access Token
async refreshToken(dto: RefreshTokenDto,user_id:number): Promise<RefreshTokenInterface> { async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
// const { user_id } = dto; const { refreshToken } = dto;
// const user_id = 1;
const session = await this.drizzle // 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');
}
const userId = Number(payload.sub); // Es más seguro usar el ID del token que el del DTO
// 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() .select()
.from(sessions) .from(sessions)
.where( .where(eq(sessions.userId, userId));
and(
eq(sessions.userId, user_id) && if (!currentSession) throw new NotFoundException('Session not found');
eq(sessions.sessionToken, dto.refresh_token),
), // 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');
// 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);
// Actualizamos DB guardando el token "viejo" como "previous"
await this.drizzle
.update(sessions)
.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: tokensNew.access_token,
access_expire_in: decodeAccess.exp,
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);
// console.log(session.length); // 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ó.
if (session.length === 0) throw new NotFoundException('session not found'); return {
const user = await this.findUserById(user_id); access_token: newAccessToken,
if (!user) throw new NotFoundException('User not found'); access_expire_in: decodeAccess.exp,
refresh_token: currentSession.sessionToken!, // Devolvemos el token 'B'
// Genera token refresh_expire_in: currentSession.expiresAt as number,
const tokens = await this.generateTokens(user); };
const decodeAccess = this.decodeToken(tokens.access_token); }
const decodeRefresh = this.decodeToken(tokens.refresh_token);
// Actualiza session
await this.drizzle
.update(sessions)
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
.where(eq(sessions.userId, user_id));
return { // -------------------------------------------------------------------
access_token: tokens.access_token, // ESCENARIO C: Robo de Token (Reuse Detection)
access_expire_in: decodeAccess.exp, // -------------------------------------------------------------------
refresh_token: tokens.refresh_token, // Si el token no es el actual, ni el anterior válido... ALGUIEN LO ROBÓ.
refresh_expire_in: decodeRefresh.exp, // 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> { async singUp(createUserDto: SingUpUserDto): Promise<User> {
// Check if username or email exists // Check if username or email exists
const data = await this.drizzle const data = await this.drizzle
.select({
id: users.id,
username: users.username,
email: users.email,
})
.from(users)
.where(
or(
eq(users.username, createUserDto.username),
eq(users.email, createUserDto.email),
),
);
if (data.length > 0) {
if (data[0].username === createUserDto.username) {
throw new HttpException(
'Username already exists',
HttpStatus.BAD_REQUEST,
);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Hash the password
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// check if user role is admin
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({ .select({
id: users.id, id: users.id,
username: users.username, username: users.username,
email: users.email email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
}) })
.from(users) .from(users)
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email))); .leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
if (data.length > 0) { .where(eq(users.id, newUser.id));
if (data[0].username === createUserDto.username) {
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
// Hash the password return userWithRole;
const hashedPassword = await bcrypt.hash(createUserDto.password, 10); });
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({
id: users.id,
username: users.username,
email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
})
.from(users)
.leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
.where(eq(users.id, newUser.id));
return userWithRole;
})
} }
} }

View File

@@ -1,3 +1,4 @@
// refresh-token
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsString } from 'class-validator'; import { IsNumber, IsString } from 'class-validator';
@@ -6,9 +7,9 @@ export class RefreshTokenDto {
@IsString({ @IsString({
message: 'Refresh token must be a string', message: 'Refresh token must be a string',
}) })
refresh_token: string; refreshToken: string;
// @ApiProperty() @ApiProperty()
// @IsNumber() @IsNumber()
// user_id: number; userId: number;
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,329 @@
import { ApiProperty } from '@nestjs/swagger';
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()
coorFullName: string;
@ApiProperty()
@IsDateString()
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()
productiveActivity: string;
@ApiProperty()
@IsString()
currentStatus: string;
@ApiProperty()
@IsInt()
@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;
@ApiProperty()
@IsString()
siturCodeCommunalCouncil: string;
@ApiProperty()
@IsString()
communalCouncilRif: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonName: string;
@ApiProperty()
@IsString()
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()
@IsOptional()
isExporting?: string;
@ApiProperty()
@IsString()
@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()
@IsOptional()
@Type(() => Number)
menCount?: number;
// === 8. LISTAS (Arrays JSON) ===
// Reciben un string JSON '[{...}]' y lo convierten a Objeto JS real
@ApiProperty()
@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()
municipality: string;
@ApiProperty()
@IsString()
parish: string;
@ApiProperty()
@IsString()
@IsOptional()
photo1?: string;
@ApiProperty()
@IsString()
@IsOptional()
photo2?: string;
@ApiProperty()
@IsString()
@IsOptional()
photo3?: string;
}

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
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) {}
// @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.',
})
async findAll(@Query() paginationDto: PaginationDto) {
const result = await this.trainingService.findAll(paginationDto);
return {
message: 'Training records fetched successfully',
data: result.data,
meta: result.meta,
};
}
@Get('statistics')
@ApiOperation({ summary: 'Get training statistics' })
@ApiResponse({ status: 200, description: 'Return training statistics.' })
async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) {
const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training statistics fetched successfully', data };
}
@Get(':id')
@ApiOperation({ summary: 'Get a training record by ID' })
@ApiResponse({ status: 200, description: 'Return the training record.' })
@ApiResponse({ status: 404, description: 'Training record not found.' })
async findOne(@Param('id') id: string) {
const data = await this.trainingService.findOne(+id);
return { message: 'Training record fetched successfully', data };
}
@Post()
@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(
@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: 404, description: 'Training record not found.' })
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: 404, description: 'Training record not found.' })
async remove(@Param('id') id: string) {
return await this.trainingService.remove(+id);
}
}

View File

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

View File

@@ -0,0 +1,687 @@
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 { 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 offset = (page - 1) * limit;
let searchCondition: SQL<unknown> | undefined;
if (search) {
searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
}
const orderBy =
sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
const data = await this.drizzle
.select()
.from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
filterDto;
const filters: SQL[] = [];
if (startDate)
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
if (endDate)
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
if (stateId) filters.push(eq(trainingSurveys.state, stateId));
if (municipalityId)
filters.push(eq(trainingSurveys.municipality, municipalityId));
if (parishId) filters.push(eq(trainingSurveys.parish, parishId));
if (ospType) filters.push(eq(trainingSurveys.ospType, ospType));
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
// 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),
// 2. Total Productores (Columna plana que mantuviste)
this.drizzle
.select({
sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
})
.from(trainingSurveys)
.where(whereCondition),
// 3. NUEVO: Total Productos (Contamos el largo del array JSON productList)
this.drizzle
.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(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus),
// 5. Distribución por Actividad
this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveActivity),
// 6. Distribución por Tipo
this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ospType),
// 7. Distribución por Estado (CORREGIDO con COALESCE)
this.drizzle
.select({
// 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)
// Importante: Agrupar también por el resultado del COALESCE o por states.name
.groupBy(states.name),
// 8. Distribución por Año
this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear),
]);
return {
totalOsps: 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),
})),
};
}
async findOne(id: number) {
const find = await this.drizzle
.select()
.from(trainingSurveys)
.where(eq(trainingSurveys.id, id));
if (find.length === 0) {
throw new HttpException(
'Training record not found',
HttpStatus.NOT_FOUND,
);
}
return find[0];
}
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({
// 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,
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)
.where(eq(trainingSurveys.id, id))
.returning();
return updatedRecord;
}
async remove(id: number) {
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,
};
}
// 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_URL = http://localhost:3000
AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE= AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE=
API_URL=http://localhost:8000 API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8000
NODE_ENV='development' #development | production

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,39 @@
// 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 { env } from '@/lib/env';
export const metadata = {
title: 'Registro de OSP',
};
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

@@ -26,7 +26,7 @@ export const metadata = {
openGraph: { openGraph: {
type: 'website', type: 'website',
title: 'fondemi', title: 'fondemi',
description: 'Sistema integral para cajas de ahorro', description: 'Sistema integral para fondemi',
url: 'https://turbo-npn.onrender.com', url: 'https://turbo-npn.onrender.com',
images: [ images: [
{ {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { NavMain as GeneralMain, NavMain as AdministrationMain, NavMain as StatisticsMain, } from '@/components/nav-main'; import { NavMain as GeneralMain, NavMain as AdministrationMain, NavMain as StatisticsMain, } from '@/components/nav-main';
import { GeneralItems, AdministrationItems, StatisticsItems } from '@/constants/data'; import { GeneralItems, AdministrationItems, StatisticsItems } from '@/constants/routes';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -15,7 +15,7 @@ import { useSession } from 'next-auth/react';
export const company = { export const company = {
name: 'Sistema para Productores', name: 'Sistema de Productores',
logo: GalleryVerticalEnd, logo: GalleryVerticalEnd,
plan: 'FONDEMI', plan: 'FONDEMI',
}; };
@@ -24,9 +24,9 @@ export const company = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession(); const { data: session } = useSession();
const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol :''; const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol : '';
// console.log(AdministrationItems[0]?.role); // console.log(AdministrationItems[0]?.role);
return ( return (
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
<SidebarHeader> <SidebarHeader>
@@ -42,15 +42,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div> </div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <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) && {StatisticsItems[0]?.role?.includes(userRole) &&
<StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole}/> <StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole} />
}
{AdministrationItems[0]?.role?.includes(userRole) &&
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole}/>
} }
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole} />
{/* <NavProjects projects={data.projects} /> */} {/* <NavProjects projects={data.projects} /> */}
</SidebarContent> </SidebarContent>
<SidebarRail /> <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,24 +10,23 @@ export const GeneralItems: NavItem[] = [
isActive: false, isActive: false,
items: [], // No child items items: [], // No child items
}, },
{ // {
title: 'ProduTienda', // title: 'ProduTienda',
url: '/dashboard/productos/', // url: '/dashboard/productos/',
icon: 'blocks', // icon: 'blocks',
shortcut: ['p', 'p'], // shortcut: ['p', 'p'],
isActive: false, // isActive: false,
items: [], // No child items // items: [], // No child items
}, // },
]; ];
export const AdministrationItems: NavItem[] = [ export const AdministrationItems: NavItem[] = [
{ {
title: 'Administracion', title: 'Administracion',
url: '#', // Placeholder as there is no direct link for the parent url: '#', // Placeholder as there is no direct link for the parent
icon: 'settings2', icon: 'settings2',
isActive: true, 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: [ items: [
{ {
@@ -35,14 +34,21 @@ export const AdministrationItems: NavItem[] = [
url: '/dashboard/administracion/usuario', url: '/dashboard/administracion/usuario',
icon: 'userPen', icon: 'userPen',
shortcut: ['m', 'm'], shortcut: ['m', 'm'],
role:['admin','superadmin'], role: ['admin', 'superadmin'],
}, },
{ {
title: 'Encuestas', title: 'Encuestas',
shortcut: ['l', 'l'], shortcut: ['l', 'l'],
url: '/dashboard/administracion/encuestas', url: '/dashboard/administracion/encuestas',
icon: 'login', 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'],
}, },
], ],
}, },
@@ -54,7 +60,7 @@ export const StatisticsItems: NavItem[] = [
url: '#', // Placeholder as there is no direct link for the parent url: '#', // Placeholder as there is no direct link for the parent
icon: 'chartColumn', icon: 'chartColumn',
isActive: true, isActive: true,
role:['admin','superadmin','autoridad'], role: ['admin', 'superadmin', 'autoridad'],
items: [ items: [
// { // {
@@ -69,13 +75,15 @@ export const StatisticsItems: NavItem[] = [
shortcut: ['l', 'l'], shortcut: ['l', 'l'],
url: '/dashboard/estadisticas/encuestas', url: '/dashboard/estadisticas/encuestas',
icon: 'notepadText', icon: 'notepadText',
role:['admin','superadmin','autoridad'], role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'Datos OSP',
shortcut: ['s', 's'],
url: '/dashboard/estadisticas/socioproductiva',
icon: 'blocks',
role: ['admin', 'superadmin', 'autoridad'],
}, },
], ],
}, },
]; ];

View File

@@ -1,5 +1,6 @@
'use server'; 'use server';
import { safeFetchApi } from '@/lib'; import { safeFetchApi } from '@/lib';
import { cookies } from 'next/headers';
import { loginResponseSchema, UserFormValue } from '../schemas/login'; import { loginResponseSchema, UserFormValue } from '../schemas/login';
type LoginActionSuccess = { type LoginActionSuccess = {
@@ -17,18 +18,18 @@ type LoginActionSuccess = {
refresh_token: string; refresh_token: string;
refresh_expire_in: number; refresh_expire_in: number;
}; };
} };
type LoginActionError = { type LoginActionError = {
type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí** type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí**
message: string; message: string;
details?: any; details?: any;
}; };
// Si SignInAction también puede devolver null, asegúralo en su tipo de retorno // Si SignInAction también puede devolver null, asegúralo en su tipo de retorno
type LoginActionResult = LoginActionSuccess | LoginActionError | null; type LoginActionResult = LoginActionSuccess | LoginActionError | null;
export const SignInAction = async (payload: UserFormValue): Promise<LoginActionResult> => { export const SignInAction = async (payload: UserFormValue) => {
const [error, data] = await safeFetchApi( const [error, data] = await safeFetchApi(
loginResponseSchema, loginResponseSchema,
'/auth/sign-in', '/auth/sign-in',
@@ -36,12 +37,22 @@ export const SignInAction = async (payload: UserFormValue): Promise<LoginActionR
payload, payload,
); );
if (error) { if (error) {
return { return error;
type: error.type as 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR',
message: error.message,
details: error.details
};
} else { } 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; 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

@@ -7,9 +7,9 @@ import {
export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => { export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
try { try {
const response = await refreshApi.patch('/auth/refresh', {refresh_token: refreshToken.token}); const response = await refreshApi.patch('/auth/refresh', refreshToken);
const parsed = RefreshTokenResponseSchema.safeParse(response.data); const parsed = RefreshTokenResponseSchema.safeParse(response.data);
if (!parsed.success) { if (!parsed.success) {
console.error('Error de validación en la respuesta de refresh token:', { console.error('Error de validación en la respuesta de refresh token:', {

View File

@@ -13,14 +13,14 @@ export function LoginForm({
return ( return (
<div className={cn("", className)} {...props}> <div className={cn("", className)} {...props}>
<Card className="overflow-hidden"> <Card className="">
<CardContent className="grid p-0 md:grid-cols-2"> <CardContent className="flex flex-col-reverse md:flex-row p-0">
<UserAuthForm /> <UserAuthForm />
<div className="relative hidden bg-muted md:block"> <div className="md:bg-muted">
<img <img
src="logo.png" src="logo.png"
alt="Image" alt="Imagen del Logo"
className="absolute inset-0 p-10 h-full w-full" className="pt-3 md:p-3 h-full w-1/3 md:w-full m-auto "
/> />
</div> </div>
</CardContent> </CardContent>

View File

@@ -15,13 +15,13 @@ export function LoginForm({
return ( return (
<div className={cn("", className)} {...props}> <div className={cn("", className)} {...props}>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardContent className="flex p-0"> <CardContent className="flex flex-col-reverse md:flex-row p-0">
<UserAuthForm /> <UserAuthForm />
<div className="hidden bg-muted md:block m-auto"> <div className="md:m-auto">
<img <img
src="logo.png" src="logo.png"
alt="Image" alt="Image"
className="inset-0 p-5" className="pt-3 md:p-5 w-1/3 md:w-full m-auto"
/> />
</div> </div>
</CardContent> </CardContent>

View File

@@ -69,11 +69,11 @@ export default function UserAuthForm() {
<> <>
<Form {...form}> <Form {...form}>
<form className="p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}> <form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center"> <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"> <p className="text-balance text-muted-foreground hidden md:block">
Ingresa tus datos Ingresa tus datos
</p> </p>
</div> </div>

View File

@@ -92,7 +92,7 @@ export default function UserAuthForm() {
<form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}> <form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="items-center text-center"> <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"> <p className="text-balance text-muted-foreground">
Ingresa tus datos Ingresa tus datos
</p> </p>
@@ -303,7 +303,7 @@ export default function UserAuthForm() {
<FormMessage className="text-red-500">{error}</FormMessage> <FormMessage className="text-red-500">{error}</FormMessage>
)}{' '} )}{' '}
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
Registrarce Registrarse
</Button> </Button>
<div className="text-center text-sm"> <div className="text-center text-sm">
¿Ya tienes una cuenta?{" "} ¿Ya tienes una cuenta?{" "}

View File

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

View File

@@ -1,14 +1,22 @@
// refreshtoken
import { z } from 'zod'; import { z } from 'zod';
import { tokensSchema } from './login'; import { tokensSchema } from './login';
// Esquema para el refresh token // Esquema para el refresh token
export const refreshTokenSchema = z.object({ export const refreshTokenSchema = z.object({
token: z.string(), refreshToken: z.string(),
}); });
export type RefreshTokenValue = z.infer<typeof refreshTokenSchema>; export type RefreshTokenValue = z.infer<typeof refreshTokenSchema>;
// Esquema final para la respuesta del backend // Esquema final para la respuesta del backend
export const RefreshTokenResponseSchema = z.object({ // export const RefreshTokenResponseSchema = z.object({
tokens: tokensSchema, // // 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'; 'use client';
import { DataTable } from '@repo/shadcn/table/data-table'; import { DataTable } from '@repo/shadcn/table/data-table';
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton'; import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
import { columns } from './product-tables/columns';
import { useProductQuery } from '../../hooks/use-query-products'; import { useProductQuery } from '../../hooks/use-query-products';
import { columns } from './product-tables/columns';
interface dataListProps { interface dataListProps {
initialPage: number; initialPage: number;
initialSearch?: string | null; initialSearch?: string | null;
initialLimit: number; initialLimit: number;
initialType?: string | null;
} }
export default function UsersAdminList({ export default function UsersAdminList({
@@ -19,9 +20,9 @@ export default function UsersAdminList({
page: initialPage, page: initialPage,
limit: initialLimit, limit: initialLimit,
...(initialSearch && { search: initialSearch }), ...(initialSearch && { search: initialSearch }),
} };
const {data, isLoading} = useProductQuery(filters) const { data, isLoading } = useProductQuery(filters);
// console.log(data?.data); // console.log(data?.data);

View File

@@ -9,9 +9,9 @@ export const columns: ColumnDef<InventoryTable>[] = [
accessorKey: 'urlImg', accessorKey: 'urlImg',
header: 'img', header: 'img',
cell: ({ row }) => { cell: ({ row }) => {
return ( 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', accessorKey: 'price',
header: 'Precio', header: 'Precio',
cell: ({ row }) => `${row.original.price}$` cell: ({ row }) => `${row.original.price} Bs.`
}, },
{ {
accessorKey: 'stock', accessorKey: 'stock',

View File

@@ -21,9 +21,9 @@ import { useForm } from 'react-hook-form';
import { useUpdateProduct } from "@/feactures/inventory/hooks/use-mutation"; import { useUpdateProduct } from "@/feactures/inventory/hooks/use-mutation";
import { updateInventory, EditInventory, InventoryTable } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad import { updateInventory, EditInventory, InventoryTable } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad
import { Textarea } from '@repo/shadcn/components/ui/textarea'; import { Textarea } from '@repo/shadcn/components/ui/textarea';
import {STATUS} from '@/constants/status' import { STATUS } from '@/constants/status'
import { useState, useEffect } from 'react'; 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 // import { z } from 'zod'; // Asegúrate de importar Zod
// --- MODIFICACIÓN CLAVE --- // --- MODIFICACIÓN CLAVE ---
@@ -57,17 +57,17 @@ export function UpdateForm({
isError, isError,
} = useUpdateProduct(); } = useUpdateProduct();
const [sizeFile, setSizeFile] = useState('0 bytes'); const [sizeFile, setSizeFile] = useState('0 bytes');
const [previewUrls, setPreviewUrls] = useState<string[]>([]); const [previewUrls, setPreviewUrls] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
return () => { return () => {
previewUrls.forEach(url => URL.revokeObjectURL(url)); previewUrls.forEach(url => URL.revokeObjectURL(url));
}; };
}, [previewUrls]); }, [previewUrls]);
const defaultformValues: EditInventory = { // Usamos el nuevo tipo aquí const defaultformValues: EditInventory = { // Usamos el nuevo tipo aquí
id: defaultValues?.id, id: defaultValues?.id,
title: defaultValues?.title || '', title: defaultValues?.title || '',
description: defaultValues?.description || '', description: defaultValues?.description || '',
price: defaultValues?.price || '', price: defaultValues?.price || '',
@@ -154,7 +154,7 @@ export function UpdateForm({
<FormItem > <FormItem >
<FormLabel>Precio</FormLabel> <FormLabel>Precio</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input type="number" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -182,7 +182,7 @@ export function UpdateForm({
<FormItem className='col-span-2'> <FormItem className='col-span-2'>
<FormLabel>Descripción</FormLabel> <FormLabel>Descripción</FormLabel>
<FormControl> <FormControl>
<Textarea {...field} className="resize-none"/> <Textarea {...field} className="resize-none" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -196,7 +196,7 @@ export function UpdateForm({
<FormItem> <FormItem>
<FormLabel>Cantidad/Stock</FormLabel> <FormLabel>Cantidad/Stock</FormLabel>
<FormControl> <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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -248,8 +248,8 @@ export function UpdateForm({
const newPreviewUrls: string[] = []; const newPreviewUrls: string[] = [];
files.forEach(element => { files.forEach(element => {
size += element.size; size += element.size;
newPreviewUrls.push(URL.createObjectURL(element)); newPreviewUrls.push(URL.createObjectURL(element));
}); });
const tamañoFormateado = sizeFormate(size); const tamañoFormateado = sizeFormate(size);
@@ -257,18 +257,18 @@ export function UpdateForm({
setPreviewUrls(newPreviewUrls); setPreviewUrls(newPreviewUrls);
onChange(e.target.files); onChange(e.target.files);
} else { } else {
setPreviewUrls([]); setPreviewUrls([]);
} }
}} }}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
{previewUrls.length > 0 && ( {previewUrls.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{previewUrls.map((url, index) => ( {previewUrls.map((url, index) => (
<img key={index} src={url} alt={`Preview ${index}`} className="w-24 h-24 object-cover rounded-md" /> <img key={index} src={url} alt={`Preview ${index}`} className="w-24 h-24 object-cover rounded-md" />
))} ))}
</div> </div>
)} )}
</FormItem> </FormItem>
)} )}

View File

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

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from "react"; import { useState } from "react";
import { allProducts } from "../../schemas/inventory"; import { allProducts } from "../../schemas/inventory";
import { import {
Card, Card,
CardContent, CardContent,
@@ -9,11 +9,11 @@ import {
CardTitle, CardTitle,
} from '@repo/shadcn/card'; } 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}`) const [selectedImg, setSelectedImg] = useState(`/uploads/inventory/${product.userId}/${product.id}/${product.urlImg}`)
console.log(product); console.log(product);
return ( return (
// <PageContainer> // <PageContainer>
<main className='px-4 lg:px-6 flex flex-col md:flex-row gap-3 lg:gap-4 md:relative'> <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'> <div className='w-full flex justify-between flex-col'>
@@ -31,21 +31,21 @@ return (
</span> </span>
</span> */} </span> */}
{product.gallery?.map((img, index) => ( {product.gallery?.map((img, index) => (
<img <img
key={index} key={index}
className="cursor-pointer border-2 object-cover w-[64px] h-[64px] md:w-[96px] md:h-[96px] aspect-square rounded-2xl" className="cursor-pointer border-2 object-cover w-[64px] h-[64px] md:w-[96px] md:h-[96px] aspect-square rounded-2xl"
src={`/uploads/inventory/${product.userId}/${product.id}/${img}`} src={`/uploads/inventory/${product.userId}/${product.id}/${img}`}
alt="" alt=""
onClick={() => setSelectedImg(`/uploads/inventory/${product.userId}/${product.id}/${img}`)} onClick={() => setSelectedImg(`/uploads/inventory/${product.userId}/${product.id}/${img}`)}
/> />
))} ))}
{/* <div className="sticky right-0 flex items-center"> {/* <div className="sticky right-0 flex items-center">
<span className="text-xl p-3 cursor-pointer bg-neutral-800/50 rounded-full text-white"> <span className="text-xl p-3 cursor-pointer bg-neutral-800/50 rounded-full text-white">
{">"} {">"}
</span> </span>
</div> */} </div> */}
</section> </section>
</div> </div>
<Card className="flex flex-col md:w-[400px] lg:w-[500px] min-h-[400px] md:h-[85vh] md:overflow-auto md:sticky top-0 right-0"> <Card className="flex flex-col md:w-[400px] lg:w-[500px] min-h-[400px] md:h-[85vh] md:overflow-auto md:sticky top-0 right-0">
@@ -53,7 +53,7 @@ return (
<CardTitle className="font-bold text-2xl text-primary"> <CardTitle className="font-bold text-2xl text-primary">
{product.title.charAt(0).toUpperCase() + product.title.slice(1)} {product.title.charAt(0).toUpperCase() + product.title.slice(1)}
</CardTitle> </CardTitle>
<p className='font-semibold'>{product.price}$ <p className='font-semibold'>{product.price} Bs.
{product.status === 'AGOTADO' ? ( {product.status === 'AGOTADO' ? (
<span className="font-semibold text-lg text-red-900"> AGOTADO</span> <span className="font-semibold text-lg text-red-900"> AGOTADO</span>
) : ('')} ) : ('')}

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
'use server';
import { safeFetchApi } from '@/lib/fetch.api';
import { trainingStatisticsResponseSchema } from '../schemas/statistics';
import {
TrainingMutate,
TrainingSchema,
trainingApiResponseSchema,
} from '../schemas/training';
export const getTrainingStatisticsAction = async (
params: {
startDate?: string;
endDate?: string;
stateId?: number;
municipalityId?: number;
parishId?: number;
ospType?: string;
} = {},
) => {
const searchParams = new URLSearchParams();
if (params.startDate) searchParams.append('startDate', params.startDate);
if (params.endDate) searchParams.append('endDate', params.endDate);
if (params.stateId) searchParams.append('stateId', params.stateId.toString());
if (params.municipalityId)
searchParams.append('municipalityId', params.municipalityId.toString());
if (params.parishId)
searchParams.append('parishId', params.parishId.toString());
if (params.ospType) searchParams.append('ospType', params.ospType);
const [error, response] = await safeFetchApi(
trainingStatisticsResponseSchema,
`/training/statistics?${searchParams.toString()}`,
'GET',
);
if (error) throw new Error(error.message);
return response?.data;
};
export const getTrainingAction = async (params: {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
...(params.search && { search: params.search }),
...(params.sortBy && { sortBy: params.sortBy }),
...(params.sortOrder && { sortOrder: params.sortOrder }),
});
const [error, response] = await safeFetchApi(
trainingApiResponseSchema,
`/training?${searchParams}`,
'GET',
);
if (error) throw new Error(error.message);
return {
data: response?.data || [],
meta: response?.meta || {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 1,
hasNextPage: false,
hasPreviousPage: false,
nextPage: null,
previousPage: null,
},
};
};
export const createTrainingAction = async (
payload: TrainingSchema | 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',
payloadToSend,
);
if (error) {
throw new Error(error.message || 'Error al crear el registro');
}
return data;
};
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');
const [error, data] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'PATCH',
payloadToSend,
);
if (error) {
throw new Error(error.message || 'Error al actualizar el registro');
}
return data;
};
export const deleteTrainingAction = async (id: number) => {
const [error] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'DELETE',
);
if (error) throw new Error(error.message || 'Error al eliminar el registro');
return true;
};
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,41 @@
-- datos basicos
nombre,
apellido,
fecha de la visita,
-->Falta
hora de la visita,
-- datos de la ubicacion
estado,
municipio,
parroquia,
nombre de la comuna,
CODIGO SITUR COMUNA,
CONSEJO COMUNAL,
CODIGO SITUR CONSEJO COMUNAL,
-- datos de la osp
actividad productiva (agricola,textil,bloquera,carpinteria,unidad de suministro),
realice una breve descripcion del requerimiento financiero,
NOMBRE DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
DIRECCIÓN DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
RIF DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
TIPO DE ORGANIZACIÓN SOCIOPRODUCTIVA,
ESTATUS ACTUAL,
AÑO DE CONSTITUCIÓN DE LA EMPRESA ,
CANTIDAD DE PRODUCTORES QUE LA CONFORMAN,
BREVE DESCRIPCIÓN DEL PRODUCTO O SERVICIO QUE OFRECE,
CAPACIDAD INSTALADA,
CAPACIDAD OPERATIVA,
¿EXPLIQUE LAS RAZONES GENERALES POR LAS CUALES LA UNIDAD DE PRODUCCIÓN TUVO QUE PARALIZARSE?
-- datos del responsable
NOMBRE Y APELLIDO DEL RESPONSABLE DE LA OSP,
CÉDULA DEL RESPONSABLE (SIN PUNTOS),
RIF DEL RESPONSABLE (SIN PUNTOS),
TELÉFONOS (COLOQUE 2 NUMEROS DE TELEFONOS),
CORREO ELECTRÓNICO,
ESTADO CIVIL DEL PRODUCTOR,
CARGA FAMILIAR,
NUMERO DE HIJOS,
-- datos adicionales
OBSERVACIONES GENERALES,
-- fotos
COLOCAR TRES (3) REGISTROS FOTOGRÁFICOS VISIBLES DEL ESPACIO Y MAQUINARIAS ACTUALMENTE (OBLIGATORIO),

View File

@@ -0,0 +1,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

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

View File

@@ -0,0 +1,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>
);
}

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