Compare commits
38 Commits
formulario
...
d29edf1f4e
| Author | SHA1 | Date | |
|---|---|---|---|
| d29edf1f4e | |||
| 200df205c9 | |||
| 00ab65aee3 | |||
| 524869b1f9 | |||
| f88ab2a971 | |||
| 0666877811 | |||
| ff46776e4a | |||
| d6de7527e4 | |||
| f910aea3cc | |||
| a88cf94adb | |||
| 70e5200549 | |||
| c70e146ce2 | |||
| fed90d9ff1 | |||
| 0efd5a11bd | |||
| e149500735 | |||
| d71ad98e85 | |||
| 590f62fad9 | |||
| 510327de58 | |||
| a0c363dd1b | |||
| 42e802f8a7 | |||
| 63c39e399e | |||
| 26fb849fa3 | |||
| 2566e5e9a7 | |||
| 8efe595f73 | |||
| d2908f1e4c | |||
| 69843e9e68 | |||
| 5c080c6d32 | |||
| 08a5567d60 | |||
| 69b3aab02a | |||
| b8b11259cd | |||
| 6482e692b3 | |||
| c1d1626e9e | |||
| 824685723b | |||
| 127e3b0e7a | |||
| ee499abcf9 | |||
| 949d54e590 | |||
| 28d51a9c00 | |||
| c1d4a40244 |
@@ -17,3 +17,10 @@ DATABASE_URL="postgresql://postgres:local**@localhost:5432/caja_ahorro" #url con
|
||||
MAIL_HOST=gmail
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_PORT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_BUCKET=
|
||||
MINIO_USE_SSL=
|
||||
|
||||
3
apps/api/.gitignore
vendored
3
apps/api/.gitignore
vendored
@@ -54,3 +54,6 @@ pids
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Uploads
|
||||
/uploads/training/*
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"builder": "swc",
|
||||
"typeCheck": true
|
||||
"typeCheck": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "features/training/export_template/*.xlsx",
|
||||
"outDir": "dist",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,15 @@
|
||||
"drizzle-orm": "0.40.0",
|
||||
"express": "5.1.0",
|
||||
"joi": "17.13.3",
|
||||
"minio": "^8.0.6",
|
||||
"moment": "2.30.1",
|
||||
"path-to-regexp": "8.2.0",
|
||||
"pg": "8.13.3",
|
||||
"pino-pretty": "13.0.0",
|
||||
"reflect-metadata": "0.2.0",
|
||||
"rxjs": "7.8.1"
|
||||
"rxjs": "7.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"xlsx-populate": "^1.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
|
||||
@@ -10,16 +10,17 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { MinioModule } from './common/minio/minio.module';
|
||||
import { DrizzleModule } from './database/drizzle.module';
|
||||
import { AuthModule } from './features/auth/auth.module';
|
||||
import { ConfigurationsModule } from './features/configurations/configurations.module';
|
||||
import { LocationModule } from './features/location/location.module'
|
||||
import { InventoryModule } from './features/inventory/inventory.module';
|
||||
import { LocationModule } from './features/location/location.module';
|
||||
import { MailModule } from './features/mail/mail.module';
|
||||
import { RolesModule } from './features/roles/roles.module';
|
||||
import { UserRolesModule } from './features/user-roles/user-roles.module';
|
||||
import { SurveysModule } from './features/surveys/surveys.module';
|
||||
import { InventoryModule } from './features/inventory/inventory.module';
|
||||
import { TrainingModule } from './features/training/training.module';
|
||||
import { UserRolesModule } from './features/user-roles/user-roles.module';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -51,6 +52,7 @@ import { TrainingModule } from './features/training/training.module';
|
||||
NodeMailerModule,
|
||||
LoggerModule,
|
||||
ThrottleModule,
|
||||
MinioModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
MailModule,
|
||||
@@ -61,7 +63,7 @@ import { TrainingModule } from './features/training/training.module';
|
||||
SurveysModule,
|
||||
LocationModule,
|
||||
InventoryModule,
|
||||
TrainingModule
|
||||
TrainingModule,
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
||||
@@ -14,6 +14,12 @@ interface EnvVars {
|
||||
MAIL_HOST: string;
|
||||
MAIL_USERNAME: string;
|
||||
MAIL_PASSWORD: string;
|
||||
MINIO_ENDPOINT: string;
|
||||
MINIO_PORT: number;
|
||||
MINIO_ACCESS_KEY: string;
|
||||
MINIO_SECRET_KEY: string;
|
||||
MINIO_BUCKET: string;
|
||||
MINIO_USE_SSL: boolean;
|
||||
}
|
||||
|
||||
const envsSchema = joi
|
||||
@@ -30,6 +36,12 @@ const envsSchema = joi
|
||||
MAIL_HOST: joi.string(),
|
||||
MAIL_USERNAME: joi.string(),
|
||||
MAIL_PASSWORD: joi.string(),
|
||||
MINIO_ENDPOINT: joi.string().required(),
|
||||
MINIO_PORT: joi.number().required(),
|
||||
MINIO_ACCESS_KEY: joi.string().required(),
|
||||
MINIO_SECRET_KEY: joi.string().required(),
|
||||
MINIO_BUCKET: joi.string().required(),
|
||||
MINIO_USE_SSL: joi.boolean().default(false),
|
||||
})
|
||||
.unknown(true);
|
||||
|
||||
@@ -54,4 +66,10 @@ export const envs = {
|
||||
mail_host: envVars.MAIL_HOST,
|
||||
mail_username: envVars.MAIL_USERNAME,
|
||||
mail_password: envVars.MAIL_PASSWORD,
|
||||
minio_endpoint: envVars.MINIO_ENDPOINT,
|
||||
minio_port: envVars.MINIO_PORT,
|
||||
minio_access_key: envVars.MINIO_ACCESS_KEY,
|
||||
minio_secret_key: envVars.MINIO_SECRET_KEY,
|
||||
minio_bucket: envVars.MINIO_BUCKET,
|
||||
minio_use_ssl: envVars.MINIO_USE_SSL,
|
||||
};
|
||||
|
||||
9
apps/api/src/common/minio/minio.module.ts
Normal file
9
apps/api/src/common/minio/minio.module.ts
Normal 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 {}
|
||||
127
apps/api/src/common/minio/minio.service.ts
Normal file
127
apps/api/src/common/minio/minio.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,17 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
{
|
||||
name: 'short',
|
||||
ttl: 1000, // 1 sec
|
||||
limit: 2,
|
||||
limit: 10,
|
||||
},
|
||||
{
|
||||
name: 'medium',
|
||||
ttl: 10000, // 10 sec
|
||||
limit: 4,
|
||||
limit: 30,
|
||||
},
|
||||
{
|
||||
name: 'long',
|
||||
ttl: 60000, // 1 min
|
||||
limit: 10,
|
||||
limit: 100,
|
||||
},
|
||||
],
|
||||
errorMessage: 'Too many requests, please try again later.',
|
||||
|
||||
36
apps/api/src/common/pipes/image-processing.pipe.ts
Normal file
36
apps/api/src/common/pipes/image-processing.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
apps/api/src/database/migrations/0010_dashing_bishop.sql
Normal file
4
apps/api/src/database/migrations/0010_dashing_bishop.sql
Normal 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;
|
||||
14
apps/api/src/database/migrations/0011_magical_thundra.sql
Normal file
14
apps/api/src/database/migrations/0011_magical_thundra.sql
Normal 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;
|
||||
20
apps/api/src/database/migrations/0012_sudden_venus.sql
Normal file
20
apps/api/src/database/migrations/0012_sudden_venus.sql
Normal 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;
|
||||
@@ -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;
|
||||
9
apps/api/src/database/migrations/0014_deep_meteorite.sql
Normal file
9
apps/api/src/database/migrations/0014_deep_meteorite.sql
Normal 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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "training_surveys" ALTER COLUMN "photo1" DROP NOT NULL;
|
||||
36
apps/api/src/database/migrations/0016_silent_tag.sql
Normal file
36
apps/api/src/database/migrations/0016_silent_tag.sql
Normal 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";
|
||||
2
apps/api/src/database/migrations/0017_mute_mole_man.sql
Normal file
2
apps/api/src/database/migrations/0017_mute_mole_man.sql
Normal 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;
|
||||
4
apps/api/src/database/migrations/0018_milky_prism.sql
Normal file
4
apps/api/src/database/migrations/0018_milky_prism.sql
Normal 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;
|
||||
10
apps/api/src/database/migrations/0019_cuddly_cobalt_man.sql
Normal file
10
apps/api/src/database/migrations/0019_cuddly_cobalt_man.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
1
apps/api/src/database/migrations/0024_petite_sabra.sql
Normal file
1
apps/api/src/database/migrations/0024_petite_sabra.sql
Normal 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");
|
||||
2
apps/api/src/database/migrations/0025_funny_makkari.sql
Normal file
2
apps/api/src/database/migrations/0025_funny_makkari.sql
Normal 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");
|
||||
2
apps/api/src/database/migrations/0026_last_vampiro.sql
Normal file
2
apps/api/src/database/migrations/0026_last_vampiro.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP VIEW "public"."v_training_surveys";--> statement-breakpoint
|
||||
ALTER TABLE "training_surveys" ADD COLUMN "productive_activity_other" text DEFAULT '';
|
||||
1850
apps/api/src/database/migrations/meta/0010_snapshot.json
Normal file
1850
apps/api/src/database/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1948
apps/api/src/database/migrations/meta/0011_snapshot.json
Normal file
1948
apps/api/src/database/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2102
apps/api/src/database/migrations/meta/0012_snapshot.json
Normal file
2102
apps/api/src/database/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2159
apps/api/src/database/migrations/meta/0013_snapshot.json
Normal file
2159
apps/api/src/database/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2222
apps/api/src/database/migrations/meta/0014_snapshot.json
Normal file
2222
apps/api/src/database/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2222
apps/api/src/database/migrations/meta/0015_snapshot.json
Normal file
2222
apps/api/src/database/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1994
apps/api/src/database/migrations/meta/0016_snapshot.json
Normal file
1994
apps/api/src/database/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2006
apps/api/src/database/migrations/meta/0017_snapshot.json
Normal file
2006
apps/api/src/database/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2046
apps/api/src/database/migrations/meta/0018_snapshot.json
Normal file
2046
apps/api/src/database/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2041
apps/api/src/database/migrations/meta/0019_snapshot.json
Normal file
2041
apps/api/src/database/migrations/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2041
apps/api/src/database/migrations/meta/0020_snapshot.json
Normal file
2041
apps/api/src/database/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2041
apps/api/src/database/migrations/meta/0021_snapshot.json
Normal file
2041
apps/api/src/database/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2098
apps/api/src/database/migrations/meta/0022_snapshot.json
Normal file
2098
apps/api/src/database/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2092
apps/api/src/database/migrations/meta/0023_snapshot.json
Normal file
2092
apps/api/src/database/migrations/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2138
apps/api/src/database/migrations/meta/0024_snapshot.json
Normal file
2138
apps/api/src/database/migrations/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2534
apps/api/src/database/migrations/meta/0025_snapshot.json
Normal file
2534
apps/api/src/database/migrations/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2099
apps/api/src/database/migrations/meta/0026_snapshot.json
Normal file
2099
apps/api/src/database/migrations/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,125 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1774379641691,
|
||||
"tag": "0026_last_vampiro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as t from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { authSchema } from './schemas';
|
||||
import * as t from 'drizzle-orm/pg-core';
|
||||
import { timestamps } from '../timestamps';
|
||||
import { states, municipalities, parishes } from './general';
|
||||
|
||||
import { municipalities, parishes, states } from './general';
|
||||
import { authSchema } from './schemas';
|
||||
|
||||
// Tabla de Usuarios sistema
|
||||
export const users = authSchema.table(
|
||||
@@ -15,9 +14,15 @@ export const users = authSchema.table(
|
||||
fullname: t.text('fullname').notNull(),
|
||||
phone: t.text('phone'),
|
||||
password: t.text('password').notNull(),
|
||||
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
|
||||
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
|
||||
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
|
||||
state: t
|
||||
.integer('state')
|
||||
.references(() => states.id, { onDelete: 'set null' }),
|
||||
municipality: t
|
||||
.integer('municipality')
|
||||
.references(() => municipalities.id, { onDelete: 'set null' }),
|
||||
parish: t
|
||||
.integer('parish')
|
||||
.references(() => parishes.id, { onDelete: 'set null' }),
|
||||
isTwoFactorEnabled: t
|
||||
.boolean('is_two_factor_enabled')
|
||||
.notNull()
|
||||
@@ -32,7 +37,6 @@ export const users = authSchema.table(
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
// Tabla de Roles
|
||||
export const roles = authSchema.table(
|
||||
'roles',
|
||||
@@ -46,8 +50,6 @@ export const roles = authSchema.table(
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
|
||||
//tabla User_roles
|
||||
export const usersRole = authSchema.table(
|
||||
'user_role',
|
||||
@@ -88,7 +90,6 @@ LEFT JOIN
|
||||
LEFT JOIN
|
||||
auth.roles r ON ur.role_id = r.id`);
|
||||
|
||||
|
||||
// Tabla de Sesiones
|
||||
export const sessions = authSchema.table(
|
||||
'sessions',
|
||||
@@ -103,6 +104,9 @@ export const sessions = authSchema.table(
|
||||
.notNull(),
|
||||
sessionToken: t.text('session_token').notNull(),
|
||||
expiresAt: t.integer('expires_at').notNull(),
|
||||
previousSessionToken: t.varchar('previous_session_token'),
|
||||
lastRotatedAt: t.timestamp('last_rotated_at'),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(sessions) => ({
|
||||
@@ -110,8 +114,6 @@ export const sessions = authSchema.table(
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
|
||||
//tabla de tokens de verificación
|
||||
export const verificationTokens = authSchema.table(
|
||||
'verificationToken',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import * as t from 'drizzle-orm/pg-core';
|
||||
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
|
||||
import { timestamps } from '../timestamps';
|
||||
import { users } from './auth';
|
||||
import { states, municipalities, parishes } from './general';
|
||||
|
||||
import { municipalities, parishes, states } from './general';
|
||||
|
||||
// Tabla surveys
|
||||
export const surveys = t.pgTable(
|
||||
@@ -19,9 +18,7 @@ export const surveys = t.pgTable(
|
||||
...timestamps,
|
||||
},
|
||||
(surveys) => ({
|
||||
surveysIndex: t
|
||||
.index('surveys_index_00')
|
||||
.on(surveys.title),
|
||||
surveysIndex: t.index('surveys_index_00').on(surveys.title),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -49,52 +46,125 @@ export const answersSurveys = t.pgTable(
|
||||
export const trainingSurveys = t.pgTable(
|
||||
'training_surveys',
|
||||
{
|
||||
// Datos basicos
|
||||
// === 1. IDENTIFICADORES Y DATOS DE VISITA ===
|
||||
id: t.serial('id').primaryKey(),
|
||||
firstname: t.text('firstname').notNull(),
|
||||
lastname: t.text('lastname').notNull(),
|
||||
coorFullName: t.text('coor_full_name').notNull(),
|
||||
visitDate: t.timestamp('visit_date').notNull(),
|
||||
// ubicacion
|
||||
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
|
||||
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
|
||||
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
|
||||
coorPhone: t.text('coor_phone'),
|
||||
|
||||
// === 2. UBICACIÓN (Claves Foráneas - Nullables) ===
|
||||
state: t
|
||||
.integer('state')
|
||||
.references(() => states.id, { onDelete: 'set null' }),
|
||||
municipality: t
|
||||
.integer('municipality')
|
||||
.references(() => municipalities.id, { onDelete: 'set null' }),
|
||||
parish: t
|
||||
.integer('parish')
|
||||
.references(() => parishes.id, { onDelete: 'set null' }),
|
||||
|
||||
// === 3. DATOS DE LA OSP (Organización Socioproductiva) ===
|
||||
ospType: t.text('osp_type').notNull(), // UPF, EPS, etc.
|
||||
ecoSector: t.text('eco_sector').notNull().default(''),
|
||||
productiveSector: t.text('productive_sector').notNull().default(''),
|
||||
centralProductiveActivity: t
|
||||
.text('central_productive_activity')
|
||||
.notNull()
|
||||
.default(''),
|
||||
mainProductiveActivity: t
|
||||
.text('main_productive_activity')
|
||||
.notNull()
|
||||
.default(''),
|
||||
productiveActivity: t.text('productive_activity').notNull(),
|
||||
productiveActivityOther: t.text('productive_activity_other').default(''),
|
||||
ospRif: t.text('osp_rif'),
|
||||
ospName: t.text('osp_name'),
|
||||
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
|
||||
currentStatus: t.text('current_status').notNull().default('ACTIVA'),
|
||||
infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''),
|
||||
hasTransport: t.boolean('has_transport').notNull().default(false),
|
||||
structureType: t.text('structure_type').notNull().default(''),
|
||||
isOpenSpace: t.boolean('is_open_space').notNull().default(false),
|
||||
paralysisReason: t.text('paralysis_reason'),
|
||||
equipmentList: t.jsonb('equipment_list').notNull().default([]),
|
||||
productionList: t.jsonb('production_list').notNull().default([]),
|
||||
productList: t.jsonb('product_list').notNull().default([]),
|
||||
ospAddress: t.text('osp_address').notNull(),
|
||||
ospGoogleMapsLink: t.text('osp_google_maps_link').notNull().default(''),
|
||||
communeName: t.text('commune_name').notNull().default(''),
|
||||
siturCodeCommune: t.text('situr_code_commune').notNull(),
|
||||
communeRif: t.text('commune_rif').notNull().default(''),
|
||||
communeSpokespersonName: t
|
||||
.text('commune_spokesperson_name')
|
||||
.notNull()
|
||||
.default(''),
|
||||
communeSpokespersonCedula: t.text('commune_spokesperson_cedula'),
|
||||
communeSpokespersonRif: t.text('commune_spokesperson_rif'),
|
||||
communeSpokespersonPhone: t
|
||||
.text('commune_spokesperson_phone')
|
||||
.notNull()
|
||||
.default(''),
|
||||
communeEmail: t.text('commune_email'),
|
||||
communalCouncil: t.text('communal_council').notNull(),
|
||||
siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(),
|
||||
// datos del OSP (ORGANIZACIÓN SOCIOPRODUCTIVA)
|
||||
ospName: t.text('osp_name').notNull(),
|
||||
ospAddress: t.text('osp_address').notNull(),
|
||||
ospRif: t.text('osp_rif').notNull(),
|
||||
ospType: t.text('osp_type').notNull(),
|
||||
productiveActivity: t.text('productive_activity').notNull(),
|
||||
financialRequirementDescription: t.text('financial_requirement_description').notNull(),
|
||||
currentStatus: t.text('current_status').notNull(),
|
||||
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
|
||||
producerCount: t.integer('producer_count').notNull(),
|
||||
productDescription: t.text('product_description').notNull(),
|
||||
installedCapacity: t.text('installed_capacity').notNull(),
|
||||
operationalCapacity: t.text('operational_capacity').notNull(),
|
||||
// datos del responsable
|
||||
communalCouncilRif: t.text('communal_council_rif').notNull().default(''),
|
||||
communalCouncilSpokespersonName: t
|
||||
.text('communal_council_spokesperson_name')
|
||||
.notNull()
|
||||
.default(''),
|
||||
communalCouncilSpokespersonCedula: t.text(
|
||||
'communal_council_spokesperson_cedula',
|
||||
),
|
||||
communalCouncilSpokespersonRif: t.text('communal_council_spokesperson_rif'),
|
||||
communalCouncilSpokespersonPhone: t
|
||||
.text('communal_council_spokesperson_phone')
|
||||
.notNull()
|
||||
.default(''),
|
||||
communalCouncilEmail: t
|
||||
.text('communal_council_email')
|
||||
.notNull()
|
||||
.default(''),
|
||||
ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(),
|
||||
ospResponsibleCedula: t.text('osp_responsible_cedula').notNull(),
|
||||
ospResponsibleRif: t.text('osp_responsible_rif').notNull(),
|
||||
ospResponsibleRif: t.text('osp_responsible_rif'),
|
||||
civilState: t.text('civil_state'),
|
||||
ospResponsiblePhone: t.text('osp_responsible_phone').notNull(),
|
||||
ospResponsibleEmail: t.text('osp_responsible_email').notNull(),
|
||||
civilState: t.text('civil_state').notNull(),
|
||||
familyBurden: t.integer('family_burden').notNull(),
|
||||
numberOfChildren: t.integer('number_of_children').notNull(),
|
||||
// datos adicionales
|
||||
generalObservations: t.text('general_observations').notNull(),
|
||||
paralysisReason: t.text('paralysis_reason').notNull(),
|
||||
// fotos
|
||||
photo1: t.text('photo1').notNull(),
|
||||
photo2: t.text('photo2').notNull(),
|
||||
photo3: t.text('photo3').notNull(),
|
||||
ospResponsibleEmail: t.text('osp_responsible_email'),
|
||||
familyBurden: t.integer('family_burden'),
|
||||
numberOfChildren: t.integer('number_of_children'),
|
||||
generalObservations: t.text('general_observations'),
|
||||
|
||||
// === 4. DATOS DE DISTRIBUCIÓN Y EXPORTACIÓN ===
|
||||
internalDistributionZone: t.text('internal_distribution_zone'),
|
||||
isExporting: t.boolean('is_exporting').notNull().default(false),
|
||||
externalCountry: t.text('external_country'),
|
||||
externalCity: t.text('external_city'),
|
||||
externalDescription: t.text('external_description'),
|
||||
externalQuantity: t.text('external_quantity'),
|
||||
externalUnit: t.text('external_unit'),
|
||||
|
||||
// === 5. MANO DE OBRA ===
|
||||
womenCount: t.integer('women_count').notNull().default(0),
|
||||
menCount: t.integer('men_count').notNull().default(0),
|
||||
|
||||
// Fotos
|
||||
photo1: t.text('photo1'),
|
||||
photo2: t.text('photo2'),
|
||||
photo3: t.text('photo3'),
|
||||
// informacion del usuario que creo y actualizo el registro
|
||||
createdBy: t
|
||||
.integer('created_by')
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
updatedBy: t
|
||||
.integer('updated_by')
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
...timestamps,
|
||||
},
|
||||
(trainingSurveys) => ({
|
||||
trainingSurveysIndex: t.index('training_surveys_index_00').on(trainingSurveys.firstname),
|
||||
})
|
||||
trainingSurveysIndex: t
|
||||
.index('training_surveys_index_00')
|
||||
.on(trainingSurveys.coorFullName),
|
||||
}),
|
||||
);
|
||||
|
||||
export const viewSurveys = t.pgView('v_surveys', {
|
||||
@@ -103,6 +173,7 @@ export const viewSurveys = t.pgView('v_surveys', {
|
||||
description: t.text('description'),
|
||||
created_at: t.timestamp('created_at'),
|
||||
closingDate: t.date('closing_date'),
|
||||
targetAudience: t.varchar('target_audience')
|
||||
}).as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
|
||||
where published = true`);
|
||||
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`);
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
// api/src/feacture/auth/auth.controller.ts
|
||||
import { Public } from '@/common/decorators';
|
||||
import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard';
|
||||
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
|
||||
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
|
||||
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
|
||||
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, Patch, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@@ -39,6 +28,8 @@ export class AuthController {
|
||||
return await this.authService.signIn(signInUserDto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@Post('sign-out')
|
||||
//@RequirePermissions('auth:sign-out')
|
||||
async signOut(@Body() signOutUserDto: SignOutUserDto) {
|
||||
@@ -58,12 +49,11 @@ export class AuthController {
|
||||
@Patch('refresh')
|
||||
//@RequirePermissions('auth:refresh-token')
|
||||
async refreshToken(@Body() refreshTokenDto: any) {
|
||||
|
||||
const data = await this.authService.refreshToken(refreshTokenDto);
|
||||
|
||||
if (!data) return null;
|
||||
// console.log('REFRESCANDO');
|
||||
// console.log(refreshTokenDto);
|
||||
// console.log('-----------');
|
||||
|
||||
return {tokens: data}
|
||||
return await this.authService.refreshToken(refreshTokenDto);
|
||||
}
|
||||
|
||||
// @Public()
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Env, validateString } from '@/common/utils';
|
||||
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
|
||||
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
|
||||
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
|
||||
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
|
||||
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
|
||||
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
|
||||
import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
|
||||
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
|
||||
import {
|
||||
@@ -24,14 +24,14 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from 'src/database/index';
|
||||
import { sessions, users, roles, usersRole } from 'src/database/index';
|
||||
import { roles, sessions, users, usersRole } from 'src/database/index';
|
||||
import { Session } from './interfaces/session.interface';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -81,33 +81,43 @@ export class AuthService {
|
||||
|
||||
//Generate Tokens
|
||||
async generateTokens(user: User): Promise<AuthTokensInterface> {
|
||||
const accessTokenSecret = envs.access_token_secret ?? '';
|
||||
const accessTokenExp = envs.access_token_expiration ?? '';
|
||||
const refreshTokenSecret = envs.refresh_token_secret ?? '';
|
||||
const refreshTokenExp = envs.refresh_token_expiration ?? '';
|
||||
|
||||
if (
|
||||
!accessTokenSecret ||
|
||||
!accessTokenExp ||
|
||||
!refreshTokenSecret ||
|
||||
!refreshTokenExp
|
||||
) {
|
||||
throw new Error('JWT environment variables are missing or invalid');
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: Number(user?.id),
|
||||
username: user.username ?? '',
|
||||
};
|
||||
|
||||
const [access_token, refresh_token] = await Promise.all([
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
{
|
||||
secret: envs.access_token_secret,
|
||||
expiresIn: envs.access_token_expiration as any,
|
||||
},
|
||||
),
|
||||
this.jwtService.signAsync(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
},
|
||||
{
|
||||
secret: envs.refresh_token_secret,
|
||||
expiresIn: envs.refresh_token_expiration as any,
|
||||
},
|
||||
),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: accessTokenSecret,
|
||||
expiresIn: accessTokenExp,
|
||||
} as JwtSignOptions),
|
||||
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: refreshTokenSecret,
|
||||
expiresIn: refreshTokenExp,
|
||||
} as JwtSignOptions),
|
||||
]);
|
||||
|
||||
return {
|
||||
access_token,
|
||||
refresh_token,
|
||||
};
|
||||
return { access_token, refresh_token };
|
||||
}
|
||||
|
||||
//Generate OTP Code For Email Confirmation
|
||||
@@ -138,7 +148,8 @@ export class AuthService {
|
||||
userId: parseInt(userId),
|
||||
expiresAt: sessionInput.expiresAt,
|
||||
});
|
||||
if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
|
||||
if (session.rowCount === 0)
|
||||
throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
|
||||
|
||||
return 'Session created successfully';
|
||||
}
|
||||
@@ -197,7 +208,6 @@ export class AuthService {
|
||||
|
||||
//Sign In User Account
|
||||
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
|
||||
|
||||
const user = await this.validateUser(dto);
|
||||
const tokens = await this.generateTokens(user);
|
||||
const decodeAccess = this.decodeToken(tokens.access_token);
|
||||
@@ -263,48 +273,116 @@ export class AuthService {
|
||||
|
||||
//Refresh User Access Token
|
||||
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
|
||||
const { user_id, refresh_token } = dto;
|
||||
// const user_id = 1;
|
||||
const { refreshToken } = dto;
|
||||
|
||||
const validation = await this.jwtService.verifyAsync(refresh_token, {
|
||||
secret: envs.refresh_token_secret,
|
||||
});
|
||||
// 1. Validar firma del token (Crypto check)
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||
secret: envs.refresh_token_secret,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid Refresh Token Signature');
|
||||
}
|
||||
|
||||
if (!validation) throw new UnauthorizedException('Invalid refresh token');
|
||||
const userId = Number(payload.sub); // Es más seguro usar el ID del token que el del DTO
|
||||
|
||||
const session = await this.drizzle
|
||||
// 2. Buscar la sesión por UserID (SIN filtrar por token todavía)
|
||||
// Esto es clave: traemos la sesión para ver qué está pasando
|
||||
const [currentSession] = await this.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, user_id) &&
|
||||
eq(sessions.sessionToken, dto.refresh_token),
|
||||
),
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
if (!currentSession) throw new NotFoundException('Session not found');
|
||||
|
||||
// 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ó.
|
||||
return {
|
||||
access_token: newAccessToken,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: currentSession.sessionToken!, // Devolvemos el token 'B'
|
||||
refresh_expire_in: currentSession.expiresAt as number,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.length === 0) throw new NotFoundException('session not found');
|
||||
const user = await this.findUserById(user_id);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
// -------------------------------------------------------------------
|
||||
// ESCENARIO C: Robo de Token (Reuse Detection)
|
||||
// -------------------------------------------------------------------
|
||||
// Si el token no es el actual, ni el anterior válido... ALGUIEN LO ROBÓ.
|
||||
// O el usuario está intentando reusar un token muy viejo.
|
||||
|
||||
// Genera token
|
||||
const tokens = await this.generateTokens(user);
|
||||
const decodeAccess = this.decodeToken(tokens.access_token);
|
||||
const decodeRefresh = this.decodeToken(tokens.refresh_token);
|
||||
// Medida de seguridad: Borrar todas las sesiones del usuario
|
||||
await this.drizzle.delete(sessions).where(eq(sessions.userId, userId));
|
||||
|
||||
// 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,
|
||||
access_expire_in: decodeAccess.exp,
|
||||
refresh_token: tokens.refresh_token,
|
||||
refresh_expire_in: decodeRefresh.exp,
|
||||
};
|
||||
throw new UnauthorizedException(
|
||||
'Refresh token reuse detected. Access revoked.',
|
||||
);
|
||||
}
|
||||
|
||||
async singUp(createUserDto: SingUpUserDto): Promise<User> {
|
||||
@@ -313,25 +391,33 @@ export class AuthService {
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email
|
||||
email: users.email,
|
||||
})
|
||||
.from(users)
|
||||
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email)));
|
||||
.where(
|
||||
or(
|
||||
eq(users.username, createUserDto.username),
|
||||
eq(users.email, createUserDto.email),
|
||||
),
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
if (data[0].username === createUserDto.username) {
|
||||
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
|
||||
throw new HttpException(
|
||||
'Username already exists',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (data[0].email === createUserDto.email) {
|
||||
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
|
||||
|
||||
// Start a transaction
|
||||
return await this.drizzle.transaction(async (tx) => {
|
||||
// Hash the password
|
||||
// Create the user
|
||||
const [newUser] = await tx
|
||||
.insert(users)
|
||||
@@ -352,6 +438,7 @@ export class AuthService {
|
||||
|
||||
// check if user role is admin
|
||||
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
|
||||
// check if user role is admin
|
||||
|
||||
// Assign role to user
|
||||
await tx.insert(usersRole).values({
|
||||
@@ -376,7 +463,6 @@ export class AuthService {
|
||||
.where(eq(users.id, newUser.id));
|
||||
|
||||
return userWithRole;
|
||||
})
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ export class RefreshTokenDto {
|
||||
@IsString({
|
||||
message: 'Refresh token must be a string',
|
||||
})
|
||||
refresh_token: string;
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
user_id: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
@@ -1,140 +1,335 @@
|
||||
import { Optional } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, IsString, IsDateString, IsOptional } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTrainingDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
firstname: string;
|
||||
// === 1. DATOS BÁSICOS ===
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
coorFullName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
lastname: string;
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
visitDate: string; // Llega como string ISO "2024-11-11T10:00"
|
||||
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
visitDate: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
coorPhone?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
productiveActivity: string;
|
||||
// === 2. DATOS OSP ===
|
||||
@ApiProperty()
|
||||
@IsOptional()
|
||||
ospName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
financialRequirementDescription: string;
|
||||
@ApiProperty()
|
||||
@IsOptional()
|
||||
ospRif: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
state: number;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospType: string; // 'UPF', etc.
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
municipality: number;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
productiveActivity: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
parish: number;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productiveActivityOther: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
siturCodeCommune: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentStatus: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
communalCouncil: string;
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Type(() => Number) // Convierte "2017" -> 2017
|
||||
companyConstitutionYear: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
siturCodeCommunalCouncil: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ospAddress: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospName: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ospGoogleMapsLink?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospAddress: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
infrastructureMt2?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospRif: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
structureType?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospType: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
hasTransport?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentStatus: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
isOpenSpace?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
companyConstitutionYear: number;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
paralysisReason?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
producerCount: number;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
generalObservations?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
productDescription: string;
|
||||
// === 3. SECTORES ===
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ecoSector: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
installedCapacity: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
productiveSector: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
operationalCapacity: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
centralProductiveActivity: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsibleFullname: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
mainProductiveActivity: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsibleCedula: string;
|
||||
// === 4. DATOS RESPONSABLE ===
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsibleFullname: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsibleRif: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsibleCedula: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsiblePhone: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ospResponsibleRif: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsibleEmail: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
ospResponsiblePhone: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
civilState: string;
|
||||
@ApiProperty()
|
||||
@IsOptional()
|
||||
@ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
|
||||
@IsEmail()
|
||||
ospResponsibleEmail?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
familyBurden: number;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
civilState: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
numberOfChildren: number;
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Type(() => Number) // Convierte "3" -> 3
|
||||
familyBurden: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
generalObservations: string;
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
numberOfChildren: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
photo1: string;
|
||||
// === 5. COMUNA Y CONSEJO COMUNAL ===
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
siturCodeCommune: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
photo2: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
communeName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
photo3: string;
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
communeRif: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
paralysisReason: 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;
|
||||
}
|
||||
|
||||
BIN
apps/api/src/features/training/export_template/excel.osp.xlsx
Normal file
BIN
apps/api/src/features/training/export_template/excel.osp.xlsx
Normal file
Binary file not shown.
@@ -1,68 +1,142 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
|
||||
import { TrainingService } from './training.service';
|
||||
import { CreateTrainingDto } from './dto/create-training.dto';
|
||||
import { UpdateTrainingDto } from './dto/update-training.dto';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
|
||||
import { ImageProcessingPipe } from '../../common/pipes/image-processing.pipe';
|
||||
import { CreateTrainingDto } from './dto/create-training.dto';
|
||||
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
|
||||
import { UpdateTrainingDto } from './dto/update-training.dto';
|
||||
import { TrainingService } from './training.service';
|
||||
|
||||
@ApiTags('training')
|
||||
@Controller('training')
|
||||
export class TrainingController {
|
||||
constructor(private readonly trainingService: TrainingService) { }
|
||||
constructor(private readonly trainingService: TrainingService) {}
|
||||
|
||||
@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
|
||||
};
|
||||
}
|
||||
// @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('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()
|
||||
@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(':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 };
|
||||
}
|
||||
@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 };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new training record' })
|
||||
@ApiResponse({ status: 201, description: 'Training record created successfully.' })
|
||||
async create(@Body() createTrainingDto: CreateTrainingDto) {
|
||||
const data = await this.trainingService.create(createTrainingDto);
|
||||
return { message: 'Training record created successfully', data };
|
||||
}
|
||||
@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 };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a training record' })
|
||||
@ApiResponse({ status: 200, description: 'Training record updated successfully.' })
|
||||
@ApiResponse({ status: 404, description: 'Training record not found.' })
|
||||
async update(@Param('id') id: string, @Body() updateTrainingDto: UpdateTrainingDto) {
|
||||
const data = await this.trainingService.update(+id, updateTrainingDto);
|
||||
return { message: 'Training record updated successfully', data };
|
||||
}
|
||||
@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 };
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,223 +1,690 @@
|
||||
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
|
||||
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { MinioService } from '@/common/minio/minio.service';
|
||||
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
|
||||
import * as schema from 'src/database/index';
|
||||
import { trainingSurveys } from 'src/database/index';
|
||||
import { eq, like, or, and, gte, lte, SQL, sql } from 'drizzle-orm';
|
||||
import { CreateTrainingDto } from './dto/create-training.dto';
|
||||
import { UpdateTrainingDto } from './dto/update-training.dto';
|
||||
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
|
||||
import { states } from 'src/database/index';
|
||||
import { states, trainingSurveys } from 'src/database/index';
|
||||
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import { CreateTrainingDto } from './dto/create-training.dto';
|
||||
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
|
||||
import { UpdateTrainingDto } from './dto/update-training.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TrainingService {
|
||||
constructor(
|
||||
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
|
||||
) { }
|
||||
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 || {};
|
||||
|
||||
async findAll(paginationDto?: PaginationDto) {
|
||||
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let searchCondition: SQL<unknown> | undefined;
|
||||
if (search) {
|
||||
searchCondition = or(
|
||||
like(trainingSurveys.firstname, `%${search}%`),
|
||||
like(trainingSurveys.lastname, `%${search}%`),
|
||||
like(trainingSurveys.ospName, `%${search}%`),
|
||||
like(trainingSurveys.ospRif, `%${search}%`)
|
||||
);
|
||||
}
|
||||
|
||||
const orderBy = sortOrder === 'asc'
|
||||
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
|
||||
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
|
||||
|
||||
const totalCountResult = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(trainingSurveys)
|
||||
.where(searchCondition);
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
const data = await this.drizzle
|
||||
.select()
|
||||
.from(trainingSurveys)
|
||||
.where(searchCondition)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const meta = {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
nextPage: page < totalPages ? page + 1 : null,
|
||||
previousPage: page > 1 ? page - 1 : null,
|
||||
};
|
||||
|
||||
return { data, meta };
|
||||
let searchCondition: SQL<unknown> | undefined;
|
||||
if (search) {
|
||||
searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
|
||||
}
|
||||
|
||||
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
|
||||
const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto;
|
||||
const orderBy =
|
||||
sortOrder === 'asc'
|
||||
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
|
||||
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
|
||||
|
||||
const filters: SQL[] = [];
|
||||
const totalCountResult = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(trainingSurveys)
|
||||
.where(searchCondition);
|
||||
|
||||
if (startDate) {
|
||||
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
|
||||
}
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
if (endDate) {
|
||||
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
|
||||
}
|
||||
const data = await this.drizzle
|
||||
.select()
|
||||
.from(trainingSurveys)
|
||||
.where(searchCondition)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (stateId) {
|
||||
filters.push(eq(trainingSurveys.state, stateId));
|
||||
}
|
||||
const meta = {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
nextPage: page < totalPages ? page + 1 : null,
|
||||
previousPage: page > 1 ? page - 1 : null,
|
||||
};
|
||||
|
||||
if (municipalityId) {
|
||||
filters.push(eq(trainingSurveys.municipality, municipalityId));
|
||||
}
|
||||
return { data, meta };
|
||||
}
|
||||
|
||||
if (parishId) {
|
||||
filters.push(eq(trainingSurveys.parish, parishId));
|
||||
}
|
||||
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
|
||||
const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
|
||||
filterDto;
|
||||
|
||||
if (ospType) {
|
||||
filters.push(eq(trainingSurveys.ospType, ospType));
|
||||
}
|
||||
const filters: SQL[] = [];
|
||||
|
||||
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
|
||||
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 totalOspsResult = await this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition);
|
||||
const totalOsps = Number(totalOspsResult[0].count);
|
||||
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
|
||||
|
||||
const totalProducersResult = await this.drizzle
|
||||
.select({ sum: sql<number>`sum(${trainingSurveys.producerCount})` })
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition);
|
||||
const totalProducers = Number(totalProducersResult[0].sum || 0);
|
||||
// Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad
|
||||
const [
|
||||
totalOspsResult,
|
||||
totalProducersResult,
|
||||
totalProductsResult, // Nuevo: Calculado desde el JSON
|
||||
statusDistribution,
|
||||
activityDistribution,
|
||||
typeDistribution,
|
||||
stateDistribution,
|
||||
yearDistribution,
|
||||
] = await Promise.all([
|
||||
// 1. Total OSPs
|
||||
this.drizzle
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition),
|
||||
|
||||
const statusDistribution = await this.drizzle
|
||||
.select({
|
||||
name: trainingSurveys.currentStatus,
|
||||
value: sql<number>`count(*)`
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.currentStatus);
|
||||
// 2. Total Productores (Columna plana que mantuviste)
|
||||
this.drizzle
|
||||
.select({
|
||||
sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition),
|
||||
|
||||
const activityDistribution = await this.drizzle
|
||||
.select({
|
||||
name: trainingSurveys.productiveActivity,
|
||||
value: sql<number>`count(*)`
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.productiveActivity);
|
||||
// 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),
|
||||
|
||||
const typeDistribution = await this.drizzle
|
||||
.select({
|
||||
name: trainingSurveys.ospType,
|
||||
value: sql<number>`count(*)`
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.ospType);
|
||||
// 4. Distribución por Estatus
|
||||
this.drizzle
|
||||
.select({
|
||||
name: trainingSurveys.currentStatus,
|
||||
value: sql<number>`count(*)`,
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.currentStatus),
|
||||
|
||||
// New Aggregations
|
||||
const stateDistribution = await this.drizzle
|
||||
.select({
|
||||
name: states.name,
|
||||
value: sql<number>`count(${trainingSurveys.id})`
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.leftJoin(states, eq(trainingSurveys.state, states.id))
|
||||
.where(whereCondition)
|
||||
.groupBy(states.name);
|
||||
// 5. Distribución por Actividad
|
||||
this.drizzle
|
||||
.select({
|
||||
name: trainingSurveys.productiveActivity,
|
||||
value: sql<number>`count(*)`,
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.productiveActivity),
|
||||
|
||||
const yearDistribution = await this.drizzle
|
||||
.select({
|
||||
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
|
||||
value: sql<number>`count(*)`
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.companyConstitutionYear)
|
||||
.orderBy(trainingSurveys.companyConstitutionYear);
|
||||
// 6. Distribución por Tipo
|
||||
this.drizzle
|
||||
.select({
|
||||
name: trainingSurveys.ospType,
|
||||
value: sql<number>`count(*)`,
|
||||
})
|
||||
.from(trainingSurveys)
|
||||
.where(whereCondition)
|
||||
.groupBy(trainingSurveys.ospType),
|
||||
|
||||
return {
|
||||
totalOsps,
|
||||
totalProducers,
|
||||
statusDistribution: statusDistribution.map(item => ({ ...item, value: Number(item.value) })),
|
||||
activityDistribution: activityDistribution.map(item => ({ ...item, value: Number(item.value) })),
|
||||
typeDistribution: typeDistribution.map(item => ({ ...item, value: Number(item.value) })),
|
||||
stateDistribution: stateDistribution.map(item => ({ ...item, value: Number(item.value) })),
|
||||
yearDistribution: yearDistribution.map(item => ({ ...item, value: Number(item.value) })),
|
||||
};
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const find = await this.drizzle
|
||||
.select()
|
||||
.from(trainingSurveys)
|
||||
.where(eq(trainingSurveys.id, id));
|
||||
return find[0];
|
||||
}
|
||||
|
||||
if (find.length === 0) {
|
||||
throw new HttpException('Training record not found', HttpStatus.NOT_FOUND);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return find[0];
|
||||
// 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) {
|
||||
const [newRecord] = await this.drizzle
|
||||
.insert(trainingSurveys)
|
||||
.values({
|
||||
...createTrainingDto,
|
||||
visitDate: new Date(createTrainingDto.visitDate),
|
||||
})
|
||||
.returning();
|
||||
async create(
|
||||
createTrainingDto: CreateTrainingDto,
|
||||
files: Express.Multer.File[],
|
||||
userId: number,
|
||||
) {
|
||||
// 1. Guardar fotos
|
||||
|
||||
return newRecord;
|
||||
}
|
||||
const photoPaths = await this.saveFiles(files);
|
||||
|
||||
async update(id: number, updateTrainingDto: UpdateTrainingDto) {
|
||||
await this.findOne(id);
|
||||
// 2. Extraer solo visitDate para formatearlo.
|
||||
// Ya NO extraemos state, municipality, etc. porque no vienen en el DTO.
|
||||
const { visitDate, state, municipality, parish, productiveActivityOther, ...rest } =
|
||||
createTrainingDto;
|
||||
|
||||
const updateData: any = { ...updateTrainingDto };
|
||||
if (updateTrainingDto.visitDate) {
|
||||
updateData.visitDate = new Date(updateTrainingDto.visitDate);
|
||||
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),
|
||||
|
||||
// Borra las tildes y cambia el texto a mayusculas
|
||||
productiveActivityOther: productiveActivityOther.toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
const [updatedRecord] = await this.drizzle
|
||||
.update(trainingSurveys)
|
||||
.set(updateData)
|
||||
.where(eq(trainingSurveys.id, id))
|
||||
.returning();
|
||||
|
||||
return updatedRecord;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
// 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));
|
||||
|
||||
const [deletedRecord] = await this.drizzle
|
||||
.delete(trainingSurveys)
|
||||
.where(eq(trainingSurveys.id, id))
|
||||
.returning();
|
||||
|
||||
return { message: 'Training record deleted successfully', data: deletedRecord };
|
||||
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();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
AUTH_URL = http://localhost:3000
|
||||
AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE=
|
||||
API_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
NODE_ENV='development' #development | production
|
||||
|
||||
|
||||
32
apps/web/app/dashboard/formulario/editar/[id]/page.tsx
Normal file
32
apps/web/app/dashboard/formulario/editar/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/dashboard/formulario/nuevo/page.tsx
Normal file
18
apps/web/app/dashboard/formulario/nuevo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,39 @@
|
||||
'use client';
|
||||
// import PageContainer from '@/components/layout/page-container';
|
||||
import { TrainingHeader } from '@/feactures/training/components/training-header';
|
||||
import TrainingList from '@/feactures/training/components/training-list';
|
||||
import TrainingTableAction from '@/feactures/training/components/training-tables/training-table-action';
|
||||
import { searchParamsCache } from '@repo/shadcn/lib/searchparams';
|
||||
import { SearchParams } from 'nuqs';
|
||||
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { CreateTrainingForm } from '@/feactures/training/components/form';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<CreateTrainingForm />
|
||||
</div>
|
||||
);
|
||||
export const metadata = {
|
||||
title: 'Registro de OSP',
|
||||
};
|
||||
|
||||
export default Page;
|
||||
type PageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const {
|
||||
page,
|
||||
q: searchQuery,
|
||||
limit,
|
||||
} = searchParamsCache.parse(await searchParams);
|
||||
|
||||
return (
|
||||
// <PageContainer>
|
||||
<div className="flex flex-1 flex-col space-y-6 p-6">
|
||||
<TrainingHeader />
|
||||
<TrainingTableAction />
|
||||
<TrainingList
|
||||
initialPage={page}
|
||||
initialSearch={searchQuery}
|
||||
initialLimit={limit || 10}
|
||||
apiUrl={env.API_URL}
|
||||
/>
|
||||
</div>
|
||||
// </PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
|
||||
export const company = {
|
||||
name: 'Sistema para Productores',
|
||||
name: 'Sistema de Productores',
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: 'FONDEMI',
|
||||
};
|
||||
@@ -42,15 +42,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole} />
|
||||
|
||||
{AdministrationItems[0]?.role?.includes(userRole) &&
|
||||
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole} />
|
||||
}
|
||||
{StatisticsItems[0]?.role?.includes(userRole) &&
|
||||
<StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole} />
|
||||
}
|
||||
|
||||
{AdministrationItems[0]?.role?.includes(userRole) &&
|
||||
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole} />
|
||||
}
|
||||
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole} />
|
||||
|
||||
|
||||
|
||||
{/* <NavProjects projects={data.projects} /> */}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
|
||||
195
apps/web/constants/countries.ts
Normal file
195
apps/web/constants/countries.ts
Normal 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',
|
||||
];
|
||||
@@ -10,32 +10,23 @@ export const GeneralItems: NavItem[] = [
|
||||
isActive: false,
|
||||
items: [], // No child items
|
||||
},
|
||||
{
|
||||
title: 'ProduTienda',
|
||||
url: '/dashboard/productos/',
|
||||
icon: 'blocks',
|
||||
shortcut: ['p', 'p'],
|
||||
isActive: false,
|
||||
items: [], // No child items
|
||||
},
|
||||
{
|
||||
title: 'Formulario',
|
||||
url: '/dashboard/formulario/',
|
||||
icon: 'notepadText',
|
||||
shortcut: ['p', 'p'],
|
||||
isActive: false,
|
||||
items: [], // No child items
|
||||
},
|
||||
// {
|
||||
// title: 'ProduTienda',
|
||||
// url: '/dashboard/productos/',
|
||||
// icon: 'blocks',
|
||||
// shortcut: ['p', 'p'],
|
||||
// isActive: false,
|
||||
// items: [], // No child items
|
||||
// },
|
||||
];
|
||||
|
||||
|
||||
export const AdministrationItems: NavItem[] = [
|
||||
{
|
||||
title: 'Administracion',
|
||||
url: '#', // Placeholder as there is no direct link for the parent
|
||||
icon: 'settings2',
|
||||
isActive: true,
|
||||
role: ['admin', 'superadmin', 'manager', 'user'], // sumatoria de los roles que si tienen acceso
|
||||
role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'], // sumatoria de los roles que si tienen acceso
|
||||
|
||||
items: [
|
||||
{
|
||||
@@ -50,7 +41,14 @@ export const AdministrationItems: NavItem[] = [
|
||||
shortcut: ['l', 'l'],
|
||||
url: '/dashboard/administracion/encuestas',
|
||||
icon: 'login',
|
||||
role: ['admin', 'superadmin', 'manager', 'user'],
|
||||
role: ['admin', 'superadmin', 'autoridad'],
|
||||
},
|
||||
{
|
||||
title: 'Registro OSP',
|
||||
shortcut: ['p', 'p'],
|
||||
url: '/dashboard/formulario/',
|
||||
icon: 'notepadText',
|
||||
role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -80,7 +78,7 @@ export const StatisticsItems: NavItem[] = [
|
||||
role: ['admin', 'superadmin', 'autoridad'],
|
||||
},
|
||||
{
|
||||
title: 'Socioproductiva',
|
||||
title: 'Datos OSP',
|
||||
shortcut: ['s', 's'],
|
||||
url: '/dashboard/estadisticas/socioproductiva',
|
||||
icon: 'blocks',
|
||||
@@ -89,8 +87,3 @@ export const StatisticsItems: NavItem[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib';
|
||||
import { cookies } from 'next/headers';
|
||||
import { loginResponseSchema, UserFormValue } from '../schemas/login';
|
||||
|
||||
type LoginActionSuccess = {
|
||||
@@ -17,18 +18,18 @@ type LoginActionSuccess = {
|
||||
refresh_token: string;
|
||||
refresh_expire_in: number;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type LoginActionError = {
|
||||
type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí**
|
||||
message: string;
|
||||
details?: any;
|
||||
type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí**
|
||||
message: string;
|
||||
details?: any;
|
||||
};
|
||||
|
||||
// Si SignInAction también puede devolver null, asegúralo en su tipo de retorno
|
||||
type LoginActionResult = LoginActionSuccess | LoginActionError | null;
|
||||
|
||||
export const SignInAction = async (payload: UserFormValue): Promise<LoginActionResult> => {
|
||||
export const SignInAction = async (payload: UserFormValue) => {
|
||||
const [error, data] = await safeFetchApi(
|
||||
loginResponseSchema,
|
||||
'/auth/sign-in',
|
||||
@@ -36,12 +37,22 @@ export const SignInAction = async (payload: UserFormValue): Promise<LoginActionR
|
||||
payload,
|
||||
);
|
||||
if (error) {
|
||||
return {
|
||||
type: error.type as 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR',
|
||||
message: error.message,
|
||||
details: error.details
|
||||
};
|
||||
return error;
|
||||
} else {
|
||||
// 2. GUARDAR REFRESH TOKEN EN COOKIE (La clave del cambio)
|
||||
|
||||
(await cookies()).set(
|
||||
'refresh_token',
|
||||
String(data?.tokens?.refresh_token),
|
||||
{
|
||||
httpOnly: true, // JavaScript no puede leerla
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 60 * 60, // Ej: 7 días (debe coincidir con tu backend)
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
47
apps/web/feactures/auth/actions/logout-action.ts
Normal file
47
apps/web/feactures/auth/actions/logout-action.ts
Normal 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');
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
// auth/actions/refresh-token-action.ts
|
||||
'use server';
|
||||
import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia
|
||||
import {
|
||||
@@ -10,7 +9,7 @@ export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
|
||||
try {
|
||||
const response = await refreshApi.patch('/auth/refresh', refreshToken);
|
||||
|
||||
const parsed = RefreshTokenResponseSchema.safeParse(response.data);
|
||||
const parsed = RefreshTokenResponseSchema.safeParse(response.data);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('Error de validación en la respuesta de refresh token:', {
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function UserAuthForm() {
|
||||
<form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Sistema para productores</h1>
|
||||
<h1 className="text-2xl font-bold">Sistema Gestión de Productores</h1>
|
||||
<p className="text-balance text-muted-foreground hidden md:block">
|
||||
Ingresa tus datos
|
||||
</p>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function UserAuthForm() {
|
||||
<form className="flex-none p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Sistema para productores</h1>
|
||||
<h1 className="text-2xl font-bold">Sistema Gestión de Productores</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Ingresa tus datos
|
||||
</p>
|
||||
@@ -303,7 +303,7 @@ export default function UserAuthForm() {
|
||||
<FormMessage className="text-red-500">{error}</FormMessage>
|
||||
)}{' '}
|
||||
<Button type="submit" className="w-full">
|
||||
Registrarce
|
||||
Registrarse
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
¿Ya tienes una cuenta?{" "}
|
||||
|
||||
5
apps/web/feactures/auth/schemas/logout.ts
Normal file
5
apps/web/feactures/auth/schemas/logout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import z from 'zod';
|
||||
|
||||
export const logoutResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
@@ -4,13 +4,19 @@ import { tokensSchema } from './login';
|
||||
|
||||
// Esquema para el refresh token
|
||||
export const refreshTokenSchema = z.object({
|
||||
user_id: z.number(),
|
||||
token: z.string(),
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
export type RefreshTokenValue = z.infer<typeof refreshTokenSchema>;
|
||||
|
||||
// Esquema final para la respuesta del backend
|
||||
export const RefreshTokenResponseSchema = z.object({
|
||||
tokens: tokensSchema,
|
||||
});
|
||||
// export const RefreshTokenResponseSchema = z.object({
|
||||
// // tokens: tokensSchema,
|
||||
// access_token: z.string(),
|
||||
// access_expire_in: z.number(),
|
||||
// refresh_token: z.string(),
|
||||
// refresh_expire_in: z.number()
|
||||
// });
|
||||
|
||||
export const RefreshTokenResponseSchema = tokensSchema
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
import { DataTable } from '@repo/shadcn/table/data-table';
|
||||
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
|
||||
import { columns } from './product-tables/columns';
|
||||
import { useProductQuery } from '../../hooks/use-query-products';
|
||||
import { columns } from './product-tables/columns';
|
||||
|
||||
interface dataListProps {
|
||||
initialPage: number;
|
||||
initialSearch?: string | null;
|
||||
initialLimit: number;
|
||||
initialType?: string | null;
|
||||
}
|
||||
|
||||
export default function UsersAdminList({
|
||||
@@ -19,9 +20,9 @@ export default function UsersAdminList({
|
||||
page: initialPage,
|
||||
limit: initialLimit,
|
||||
...(initialSearch && { search: initialSearch }),
|
||||
}
|
||||
};
|
||||
|
||||
const {data, isLoading} = useProductQuery(filters)
|
||||
const { data, isLoading } = useProductQuery(filters);
|
||||
|
||||
// console.log(data?.data);
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ export const columns: ColumnDef<InventoryTable>[] = [
|
||||
accessorKey: 'urlImg',
|
||||
header: 'img',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<img src={`/uploads/inventory/${row.original.userId}/${row.original.id}/${row.original.urlImg}`} alt="" width={64} height={64} className="rounded"/>
|
||||
)
|
||||
return (
|
||||
<img src={`/uploads/inventory/${row.original.userId}/${row.original.id}/${row.original.urlImg}`} alt="" width={64} height={64} className="rounded" />
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -27,7 +27,7 @@ export const columns: ColumnDef<InventoryTable>[] = [
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Precio',
|
||||
cell: ({ row }) => `${row.original.price}$`
|
||||
cell: ({ row }) => `${row.original.price} Bs.`
|
||||
},
|
||||
{
|
||||
accessorKey: 'stock',
|
||||
|
||||
@@ -21,9 +21,9 @@ import { useForm } from 'react-hook-form';
|
||||
import { useUpdateProduct } from "@/feactures/inventory/hooks/use-mutation";
|
||||
import { updateInventory, EditInventory, InventoryTable } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad
|
||||
import { Textarea } from '@repo/shadcn/components/ui/textarea';
|
||||
import {STATUS} from '@/constants/status'
|
||||
import { STATUS } from '@/constants/status'
|
||||
import { useState, useEffect } from 'react';
|
||||
import {sizeFormate} from "@/feactures/inventory/utils/sizeFormate"
|
||||
import { sizeFormate } from "@/feactures/inventory/utils/sizeFormate"
|
||||
// import { z } from 'zod'; // Asegúrate de importar Zod
|
||||
|
||||
// --- MODIFICACIÓN CLAVE ---
|
||||
@@ -57,17 +57,17 @@ export function UpdateForm({
|
||||
isError,
|
||||
} = useUpdateProduct();
|
||||
|
||||
const [sizeFile, setSizeFile] = useState('0 bytes');
|
||||
const [sizeFile, setSizeFile] = useState('0 bytes');
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previewUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [previewUrls]);
|
||||
}, [previewUrls]);
|
||||
|
||||
const defaultformValues: EditInventory = { // Usamos el nuevo tipo aquí
|
||||
id: defaultValues?.id,
|
||||
id: defaultValues?.id,
|
||||
title: defaultValues?.title || '',
|
||||
description: defaultValues?.description || '',
|
||||
price: defaultValues?.price || '',
|
||||
@@ -154,7 +154,7 @@ export function UpdateForm({
|
||||
<FormItem >
|
||||
<FormLabel>Precio</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -182,7 +182,7 @@ export function UpdateForm({
|
||||
<FormItem className='col-span-2'>
|
||||
<FormLabel>Descripción</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} className="resize-none"/>
|
||||
<Textarea {...field} className="resize-none" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -196,7 +196,7 @@ export function UpdateForm({
|
||||
<FormItem>
|
||||
<FormLabel>Cantidad/Stock</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" onChange={(e) => field.onChange(Number(e.target.value))}/>
|
||||
<Input {...field} type="number" onChange={(e) => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -248,8 +248,8 @@ export function UpdateForm({
|
||||
const newPreviewUrls: string[] = [];
|
||||
|
||||
files.forEach(element => {
|
||||
size += element.size;
|
||||
newPreviewUrls.push(URL.createObjectURL(element));
|
||||
size += element.size;
|
||||
newPreviewUrls.push(URL.createObjectURL(element));
|
||||
});
|
||||
|
||||
const tamañoFormateado = sizeFormate(size);
|
||||
@@ -257,18 +257,18 @@ export function UpdateForm({
|
||||
setPreviewUrls(newPreviewUrls);
|
||||
onChange(e.target.files);
|
||||
} else {
|
||||
setPreviewUrls([]);
|
||||
setPreviewUrls([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{previewUrls.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{previewUrls.map((url, index) => (
|
||||
<img key={index} src={url} alt={`Preview ${index}`} className="w-24 h-24 object-cover rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{previewUrls.map((url, index) => (
|
||||
<img key={index} src={url} alt={`Preview ${index}`} className="w-24 h-24 object-cover rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ProductCard({ product, onClick }: cardProps) {
|
||||
{product.status === 'AGOTADO' ? (
|
||||
<p className="font-semibold text-lg text-red-900">AGOTADO</p>
|
||||
) : ('')}
|
||||
<p className="font-semibold text-lg">$ {product.price}</p>
|
||||
<p className="font-semibold text-lg">{product.price} Bs.</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { useState } from "react";
|
||||
import { allProducts } from "../../schemas/inventory";
|
||||
import { allProducts } from "../../schemas/inventory";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
CardTitle,
|
||||
} from '@repo/shadcn/card';
|
||||
|
||||
export function ProductList({product}: {product: allProducts}) {
|
||||
const [selectedImg, setSelectedImg] = useState(`/uploads/inventory/${product.userId}/${product.id}/${product.urlImg}`)
|
||||
export function ProductList({ product }: { product: allProducts }) {
|
||||
const [selectedImg, setSelectedImg] = useState(`/uploads/inventory/${product.userId}/${product.id}/${product.urlImg}`)
|
||||
console.log(product);
|
||||
|
||||
return (
|
||||
|
||||
return (
|
||||
// <PageContainer>
|
||||
<main className='px-4 lg:px-6 flex flex-col md:flex-row gap-3 lg:gap-4 md:relative'>
|
||||
<div className='w-full flex justify-between flex-col'>
|
||||
@@ -31,21 +31,21 @@ return (
|
||||
</span>
|
||||
</span> */}
|
||||
{product.gallery?.map((img, index) => (
|
||||
<img
|
||||
key={index}
|
||||
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}`}
|
||||
alt=""
|
||||
onClick={() => setSelectedImg(`/uploads/inventory/${product.userId}/${product.id}/${img}`)}
|
||||
/>
|
||||
))}
|
||||
{/* <div className="sticky right-0 flex items-center">
|
||||
<img
|
||||
key={index}
|
||||
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}`}
|
||||
alt=""
|
||||
onClick={() => setSelectedImg(`/uploads/inventory/${product.userId}/${product.id}/${img}`)}
|
||||
/>
|
||||
))}
|
||||
{/* <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>
|
||||
</div> */}
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
</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">
|
||||
@@ -53,7 +53,7 @@ return (
|
||||
<CardTitle className="font-bold text-2xl text-primary">
|
||||
{product.title.charAt(0).toUpperCase() + product.title.slice(1)}
|
||||
</CardTitle>
|
||||
<p className='font-semibold'>{product.price}$
|
||||
<p className='font-semibold'>{product.price} Bs.
|
||||
{product.status === 'AGOTADO' ? (
|
||||
<span className="font-semibold text-lg text-red-900"> AGOTADO</span>
|
||||
) : ('')}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { Heading } from '@repo/shadcn/heading';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function SurveysHeader() {
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
const role = session?.user.role[0]?.rol;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -14,11 +16,18 @@ export function SurveysHeader() {
|
||||
title="Administración de Encuestas"
|
||||
description="Gestiona las encuestas disponibles en la plataforma"
|
||||
/>
|
||||
<Button onClick={() => router.push(`/dashboard/administracion/encuestas/crear`)} size="sm">
|
||||
<Plus className="h-4 w-4"/><span className='hidden sm:inline'>Agregar Encuesta</span>
|
||||
</Button>
|
||||
{['superadmin', 'admin'].includes(role ?? '') && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/administracion/encuestas/crear`)
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Agregar Encuesta</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertModal } from '@/components/modal/alert-modal';
|
||||
import { useDeleteSurvey } from '@/feactures/surveys/hooks/use-mutation-surveys';
|
||||
import { SurveyTable } from '@/feactures/surveys/schemas/survey';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@repo/shadcn/tooltip';
|
||||
import { Edit, Trash } from 'lucide-react';
|
||||
import { SurveyTable } from '@/feactures/surveys/schemas/survey';
|
||||
import { useDeleteSurvey } from '@/feactures/surveys/hooks/use-mutation-surveys';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CellActionProps {
|
||||
data: SurveyTable;
|
||||
@@ -23,6 +23,7 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: deleteSurvey } = useDeleteSurvey();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
@@ -36,6 +37,8 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const role = session?.user.role[0]?.rol;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertModal
|
||||
@@ -47,41 +50,48 @@ export const CellAction: React.FC<CellActionProps> = ({ data }) => {
|
||||
description="Esta acción no se puede deshacer."
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/dashboard/administracion/encuestas/editar/${data.id!}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Editar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{['superadmin', 'admin'].includes(role ?? '') && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/dashboard/administracion/encuestas/editar/${data.id!}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Editar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,123 +1,161 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import {
|
||||
TrainingSchema,
|
||||
TrainingMutate,
|
||||
trainingApiResponseSchema
|
||||
} from '../schemas/training';
|
||||
import { trainingStatisticsResponseSchema } from '../schemas/statistics';
|
||||
import {
|
||||
TrainingMutate,
|
||||
TrainingSchema,
|
||||
trainingApiResponseSchema,
|
||||
} from '../schemas/training';
|
||||
|
||||
export const getTrainingStatisticsAction = async (params: {
|
||||
export const getTrainingStatisticsAction = async (
|
||||
params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
stateId?: number;
|
||||
municipalityId?: number;
|
||||
parishId?: number;
|
||||
ospType?: string;
|
||||
} = {}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.startDate) searchParams.append('startDate', params.startDate);
|
||||
if (params.endDate) searchParams.append('endDate', params.endDate);
|
||||
if (params.stateId) searchParams.append('stateId', params.stateId.toString());
|
||||
if (params.municipalityId) searchParams.append('municipalityId', params.municipalityId.toString());
|
||||
if (params.parishId) searchParams.append('parishId', params.parishId.toString());
|
||||
if (params.ospType) searchParams.append('ospType', params.ospType);
|
||||
} = {},
|
||||
) => {
|
||||
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',
|
||||
);
|
||||
const [error, response] = await safeFetchApi(
|
||||
trainingStatisticsResponseSchema,
|
||||
`/training/statistics?${searchParams.toString()}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
|
||||
export const getTrainingAction = async (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}) => {
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: (params.page || 1).toString(),
|
||||
limit: (params.limit || 10).toString(),
|
||||
...(params.search && { search: params.search }),
|
||||
...(params.sortBy && { sortBy: params.sortBy }),
|
||||
...(params.sortOrder && { sortOrder: params.sortOrder }),
|
||||
});
|
||||
|
||||
const [error, response] = await safeFetchApi(
|
||||
trainingApiResponseSchema,
|
||||
`/training?${searchParams}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
return {
|
||||
data: response?.data || [],
|
||||
meta: response?.meta || {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalCount: 0,
|
||||
totalPages: 1,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
nextPage: null,
|
||||
previousPage: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const createTrainingAction = async (payload: TrainingSchema) => {
|
||||
const { id, ...payloadWithoutId } = payload;
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
TrainingMutate,
|
||||
'/training',
|
||||
'POST',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Error al crear el registro');
|
||||
}
|
||||
|
||||
return data;
|
||||
return response?.data;
|
||||
};
|
||||
|
||||
export const updateTrainingAction = async (payload: TrainingSchema) => {
|
||||
const { id, ...payloadWithoutId } = payload;
|
||||
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 }),
|
||||
});
|
||||
|
||||
if (!id) throw new Error('ID es requerido para actualizar');
|
||||
const [error, response] = await safeFetchApi(
|
||||
trainingApiResponseSchema,
|
||||
`/training?${searchParams}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
TrainingMutate,
|
||||
`/training/${id}`,
|
||||
'PATCH',
|
||||
payloadWithoutId,
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Error al actualizar el registro');
|
||||
}
|
||||
return {
|
||||
data: response?.data || [],
|
||||
meta: response?.meta || {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalCount: 0,
|
||||
totalPages: 1,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
nextPage: null,
|
||||
previousPage: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return data;
|
||||
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'
|
||||
)
|
||||
const [error] = await safeFetchApi(
|
||||
TrainingMutate,
|
||||
`/training/${id}`,
|
||||
'DELETE',
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.message || 'Error al eliminar el registro');
|
||||
if (error) throw new Error(error.message || 'Error al eliminar el registro');
|
||||
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
175
apps/web/feactures/training/components/equipment-list.tsx
Normal file
175
apps/web/feactures/training/components/equipment-list.tsx
Normal 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
205
apps/web/feactures/training/components/product-activity-list.tsx
Normal file
205
apps/web/feactures/training/components/product-activity-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
apps/web/feactures/training/components/production-list.tsx
Normal file
204
apps/web/feactures/training/components/production-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/web/feactures/training/components/training-header.tsx
Normal file
13
apps/web/feactures/training/components/training-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/feactures/training/components/training-list.tsx
Normal file
48
apps/web/feactures/training/components/training-list.tsx
Normal 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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,225 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { useTrainingStatsQuery } from '../hooks/use-training-statistics';
|
||||
import { Input } from '@repo/shadcn/input';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { SelectSearchable } from '@repo/shadcn/select-searchable';
|
||||
import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
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',
|
||||
'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>('');
|
||||
// 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);
|
||||
// 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' }];
|
||||
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,
|
||||
});
|
||||
// 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'];
|
||||
const handleClearFilters = () => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setStateId(0);
|
||||
setMunicipalityId(0);
|
||||
setParishId(0);
|
||||
setOspType('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
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>
|
||||
<div className="flex justify-center p-8">Cargando estadísticas...</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 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>
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">No hay datos disponibles.</div>
|
||||
);
|
||||
}
|
||||
|
||||
<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>
|
||||
const {
|
||||
totalOsps,
|
||||
totalProducers,
|
||||
statusDistribution,
|
||||
activityDistribution,
|
||||
typeDistribution,
|
||||
stateDistribution,
|
||||
yearDistribution,
|
||||
} = data;
|
||||
|
||||
{/* State Distribution */}
|
||||
<Card className="col-span-full">
|
||||
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>
|
||||
@@ -239,81 +290,84 @@ export function TrainingStatistics() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
{/* 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>
|
||||
);
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { searchParams } from '@repo/shadcn/lib/searchparams';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export function useTrainingTableFilters() {
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'q',
|
||||
searchParams.q
|
||||
.withOptions({
|
||||
shallow: false,
|
||||
throttleMs: 500,
|
||||
})
|
||||
.withDefault(''),
|
||||
);
|
||||
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
searchParams.page.withDefault(1),
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchQuery(null);
|
||||
setPage(1);
|
||||
}, [setSearchQuery, setPage]);
|
||||
|
||||
const isAnyFilterActive = useMemo(() => {
|
||||
return !!searchQuery;
|
||||
}, [searchQuery]);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
page,
|
||||
setPage,
|
||||
resetFilters,
|
||||
isAnyFilterActive,
|
||||
};
|
||||
}
|
||||
558
apps/web/feactures/training/components/training-view-modal.tsx
Normal file
558
apps/web/feactures/training/components/training-view-modal.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useMunicipalityQuery,
|
||||
useParishQuery,
|
||||
useStateQuery,
|
||||
} from '@/feactures/location/hooks/use-query-location';
|
||||
import { Badge } from '@repo/shadcn/badge';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@repo/shadcn/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@repo/shadcn/components/ui/dialog';
|
||||
import { ScrollArea } from '@repo/shadcn/components/ui/scroll-area';
|
||||
import {
|
||||
ExternalLink,
|
||||
Factory,
|
||||
MapPin,
|
||||
Package,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { TrainingSchema } from '../schemas/training';
|
||||
|
||||
interface TrainingViewModalProps {
|
||||
data: TrainingSchema | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TrainingViewModal({
|
||||
data,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: TrainingViewModalProps) {
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
const { data: statesData } = useStateQuery();
|
||||
const { data: municipalitiesData } = useMunicipalityQuery(data?.state || 0);
|
||||
const { data: parishesData } = useParishQuery(data?.municipality || 0);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const stateName = statesData?.data?.find(
|
||||
(s: any) => s.id === data.state,
|
||||
)?.name;
|
||||
const municipalityName = municipalitiesData?.data?.find(
|
||||
(m: any) => m.id === data.municipality,
|
||||
)?.name;
|
||||
const parishName = parishesData?.data?.find(
|
||||
(p: any) => p.id === data.parish,
|
||||
)?.name;
|
||||
|
||||
const DetailItem = ({ label, value }: { label: string; value: any }) => (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground break-words">
|
||||
{value !== null && value !== undefined && value !== '' ? value : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Card className="overflow-hidden border-l-4 border-l-primary/20">
|
||||
<CardHeader className="py-3 bg-muted/30">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{Icon && <Icon className="h-5 w-5 text-primary" />}
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-4 gap-y-6 pt-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const BooleanBadge = ({ value }: { value?: boolean | null }) => (
|
||||
<Badge variant={value ? 'default' : 'secondary'}>
|
||||
{value ? 'Sí' : 'No'}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
// console.log(data);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[1000px] max-h-[90vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Factory className="h-6 w-6" />
|
||||
{data.ospName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{data.ospType} • {data.ospRif} •{' '}
|
||||
<span
|
||||
className={
|
||||
data.currentStatus === 'ACTIVA'
|
||||
? 'text-green-600 font-medium'
|
||||
: 'text-red-600'
|
||||
}
|
||||
>
|
||||
{data.currentStatus}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 px-6 py-6">
|
||||
<div className="space-y-8">
|
||||
{/* 1. Datos de la Visita */}
|
||||
<Section title="Datos de la Visita">
|
||||
<DetailItem label="Coordinador" value={data.coorFullName} />
|
||||
<DetailItem label="Teléfono Coord." value={data.coorPhone} />
|
||||
<DetailItem
|
||||
label="Fecha Visita"
|
||||
value={
|
||||
data.visitDate
|
||||
? new Date(data.visitDate).toLocaleString()
|
||||
: 'N/A'
|
||||
}
|
||||
/>
|
||||
<DetailItem label="Estado" value={stateName} />
|
||||
<DetailItem label="Municipio" value={municipalityName} />
|
||||
<DetailItem label="Parroquia" value={parishName} />
|
||||
</Section>
|
||||
|
||||
{/* 2. Sectores y Actividad */}
|
||||
<Section title="Sectores Económicos">
|
||||
<DetailItem label="Sector Económico" value={data.ecoSector} />
|
||||
<DetailItem
|
||||
label="Sector Productivo"
|
||||
value={data.productiveSector}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Actividad Central"
|
||||
value={data.centralProductiveActivity}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Actividad Principal"
|
||||
value={data.mainProductiveActivity}
|
||||
/>
|
||||
{/* <div className="sm-col-span-full"> */}
|
||||
<DetailItem
|
||||
label="Actividad Específica"
|
||||
value={data.productiveActivity}
|
||||
/>
|
||||
|
||||
{data.productiveActivity == 'OTRO' && (<DetailItem
|
||||
label="Otra Actividad Específica"
|
||||
value={data.productiveActivityOther}
|
||||
/>)}
|
||||
{/* </div> */}
|
||||
</Section>
|
||||
|
||||
{/* 3. Infraestructura y Ubicación */}
|
||||
<Section title="Infraestructura y Ubicación" icon={MapPin}>
|
||||
<DetailItem
|
||||
label="Año Constitución"
|
||||
value={data.companyConstitutionYear}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Infraestructura (m²)"
|
||||
value={data.infrastructureMt2}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Tipo Estructura"
|
||||
value={data.structureType}
|
||||
/>
|
||||
|
||||
<DetailItem
|
||||
label="Posee Transporte"
|
||||
value={<BooleanBadge value={data.hasTransport} />}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Espacio Abierto"
|
||||
value={<BooleanBadge value={data.isOpenSpace} />}
|
||||
/>
|
||||
|
||||
<div className="col-span-full space-y-4 mt-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
Dirección
|
||||
</p>
|
||||
<p className="text-sm font-medium">{data.ospAddress}</p>
|
||||
</div>
|
||||
{data.ospGoogleMapsLink && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="gap-2"
|
||||
>
|
||||
<a
|
||||
href={
|
||||
data.ospGoogleMapsLink.startsWith('http')
|
||||
? data.ospGoogleMapsLink
|
||||
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(data.ospGoogleMapsLink)}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<MapPin className="h-4 w-4" />
|
||||
Ver en Google Maps
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 4. LISTAS DETALLADAS (Lo nuevo) */}
|
||||
|
||||
{/* PRODUCTOS */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Productos Registrados
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{data.productList?.length || 0}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.productList?.map((prod: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-muted/40 p-4 rounded-lg border text-sm"
|
||||
>
|
||||
<h4 className="font-bold text-base text-primary mb-2">
|
||||
{prod.description}
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<DetailItem label="Diario" value={prod.dailyCount} />
|
||||
<DetailItem
|
||||
label="Semanal"
|
||||
value={prod.weeklyCount}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Mensual"
|
||||
value={prod.monthlyCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(!data.productList || data.productList.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No hay productos registrados.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DISTRIBUCIÓN, EXPORTACIÓN Y MANO DE OBRA */}
|
||||
<Section title="Distribución, Exportación y Mano de Obra">
|
||||
<div className="col-span-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-bold border-b pb-1">
|
||||
Distribución Interna
|
||||
</h4>
|
||||
<DetailItem
|
||||
label="Zona de Distribución"
|
||||
value={data.internalDistributionZone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-bold border-b pb-1">
|
||||
Mano de Obra
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailItem label="Mujeres" value={data.womenCount} />
|
||||
<DetailItem label="Hombres" value={data.menCount} />
|
||||
<DetailItem
|
||||
label="Total"
|
||||
value={
|
||||
Number(data.womenCount || 0) +
|
||||
Number(data.menCount || 0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-bold border-b pb-1 flex items-center gap-2">
|
||||
Exportación <BooleanBadge value={data.isExporting} />
|
||||
</h4>
|
||||
{data.isExporting && (
|
||||
<div className="space-y-3">
|
||||
<DetailItem label="País" value={data.externalCountry} />
|
||||
<DetailItem label="Ciudad" value={data.externalCity} />
|
||||
<DetailItem
|
||||
label="Descripción"
|
||||
value={data.externalDescription}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<DetailItem
|
||||
label="Cantidad"
|
||||
value={data.externalQuantity}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Unidad"
|
||||
value={data.externalUnit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* EQUIPAMIENTO Y PRODUCCIÓN */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Wrench className="h-5 w-5" />
|
||||
Equipamiento
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.equipmentList?.map((eq: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex justify-between items-center p-2 rounded bg-muted/40 border"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{eq.machine}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{eq.specifications}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm font-bold h-8 w-8 flex items-center justify-center rounded-full"
|
||||
>
|
||||
{eq.quantity}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{(!data.equipmentList ||
|
||||
data.equipmentList.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No hay equipamiento registrado.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Factory className="h-5 w-5" />
|
||||
Materia Prima
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.productionList?.map((mat: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex justify-between items-center p-2 rounded bg-muted/40 border"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{mat.rawMaterial}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mat.supplyType}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
Cant: {mat.quantity} {mat.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{(!data.productionList ||
|
||||
data.productionList.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No hay materia prima registrada.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 5. Comuna y Responsable */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Section title="Datos de la Comuna">
|
||||
<DetailItem label="Comuna" value={data.communeName} />
|
||||
<DetailItem
|
||||
label="Código SITUR"
|
||||
value={data.siturCodeCommune}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Vocero"
|
||||
value={data.communeSpokespersonName}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Teléfono"
|
||||
value={data.communeSpokespersonPhone}
|
||||
/>
|
||||
<div className="col-span-full border-t pt-4 mt-2">
|
||||
<DetailItem
|
||||
label="Consejo Comunal"
|
||||
value={data.communalCouncil}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Vocero C.C."
|
||||
value={data.communalCouncilSpokespersonName}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Responsable OSP">
|
||||
<DetailItem
|
||||
label="Nombre"
|
||||
value={data.ospResponsibleFullname}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Cédula"
|
||||
value={data.ospResponsibleCedula}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Teléfono"
|
||||
value={data.ospResponsiblePhone}
|
||||
/>
|
||||
{/* <DetailItem label="Email" value={data.ospResponsibleEmail} />
|
||||
<DetailItem
|
||||
label="Carga Familiar"
|
||||
value={data.familyBurden}
|
||||
/>
|
||||
<DetailItem label="Hijos" value={data.numberOfChildren} /> */}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* 6. Observaciones */}
|
||||
{(data.generalObservations || data.paralysisReason) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Observaciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{data.generalObservations && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-muted-foreground uppercase mb-1">
|
||||
Generales
|
||||
</p>
|
||||
<p className="text-sm">{data.generalObservations}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.paralysisReason && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-md border border-red-200 dark:border-red-900">
|
||||
<p className="text-xs font-bold text-red-600 dark:text-red-400 uppercase mb-1">
|
||||
Motivo de Paralización
|
||||
</p>
|
||||
<p className="text-sm">{data.paralysisReason}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 7. Fotos */}
|
||||
<Section title="Registro Fotográfico">
|
||||
{[data.photo1, data.photo2, data.photo3].some(Boolean) ? (
|
||||
<div className="col-span-full grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[data.photo1, data.photo2, data.photo3].map(
|
||||
(photo, idx) =>
|
||||
photo && (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative aspect-video rounded-lg overflow-hidden cursor-zoom-in border hover:shadow-lg transition-all"
|
||||
onClick={() => setSelectedImage(photo)}
|
||||
>
|
||||
<img
|
||||
src={`${photo}`}
|
||||
alt={`Evidencia ${idx + 1}`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground col-span-full">
|
||||
No hay imágenes cargadas.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t bg-muted/20">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cerrar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Lightbox para imágenes */}
|
||||
<Dialog
|
||||
open={!!selectedImage}
|
||||
onOpenChange={() => setSelectedImage(null)}
|
||||
>
|
||||
<DialogContent className="max-w-[95vw] max-h-[95vh] p-0 overflow-hidden bg-black/95 border-none">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Imagen Ampliada</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription></DialogDescription>
|
||||
<div className="relative w-full h-full flex items-center justify-center p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 text-white hover:bg-white/20 rounded-full z-50"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
{selectedImage && (
|
||||
<img
|
||||
src={`${selectedImage}`}
|
||||
alt="Vista ampliada"
|
||||
className="max-w-full max-h-[90vh] object-contain rounded-md"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
178
apps/web/feactures/training/constants/osp-data.ts
Normal file
178
apps/web/feactures/training/constants/osp-data.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
export const SECTOR_ECONOMICO = {
|
||||
PRIMARIO: 'PRIMARIO',
|
||||
SECUNDARIO: 'SECUNDARIO',
|
||||
TERCIARIO: 'TERCIARIO',
|
||||
} as const;
|
||||
|
||||
export const SECTOR_PRODUCTIVO = {
|
||||
AGRICOLA: 'AGRÍCOLA',
|
||||
MANUFACTURA: 'MANUFACTURA',
|
||||
SERVICIOS: 'SERVICIOS',
|
||||
TURISMO: 'TURISMO',
|
||||
COMERCIO: 'COMERCIO',
|
||||
} as const;
|
||||
|
||||
export const ACTIVIDAD_CENTRAL = {
|
||||
PRODUCCION_VEGETAL: 'PRODUCCIÓN VEGETAL',
|
||||
PRODUCCION_ANIMAL: 'PRODUCCIÓN ANIMAL',
|
||||
PRODUCCION_VEGETAL_ANIMAL: 'PRODUCCIÓN VEGETAL Y ANIMAL',
|
||||
INDUSTRIAL: 'INDUSTRIAL',
|
||||
SERVICIOS: 'SERVICIOS',
|
||||
TURISMO: 'TURISMO',
|
||||
COMERCIO: 'COMERCIO',
|
||||
} as const;
|
||||
|
||||
export const ACTIVIDAD_PRINCIPAL = {
|
||||
AGRICULTURA: 'AGRICULTURA',
|
||||
CRIA: 'CRIA',
|
||||
PATIOS_PRODUCTIVOS: 'PATIOS PRODUCTIVOS O CONUCOS',
|
||||
TRANSFORMACION_MATERIA: 'TRANSFORMACION DE LA MATERIA PRIMA',
|
||||
TEXTIL: 'TALLER DE COFECCION TEXTIL',
|
||||
CONSTRUCCION: 'CONSTRUCCION',
|
||||
BIENES_SERVICIOS: 'OFRECER PRODUCTOS DE BIENES Y SERVICIOS',
|
||||
VISITAS_GUIADAS: 'VISITAS GUIADAS',
|
||||
ALOJAMIENTO: 'ALOJAMIENTO',
|
||||
TURISMO: 'TURISMO',
|
||||
COMERCIO: 'COMERCIO',
|
||||
} as const;
|
||||
|
||||
export const SECTOR_ECONOMICO_OPTIONS = [
|
||||
SECTOR_ECONOMICO.PRIMARIO,
|
||||
SECTOR_ECONOMICO.SECUNDARIO,
|
||||
SECTOR_ECONOMICO.TERCIARIO,
|
||||
];
|
||||
|
||||
// Map: Sector Economico -> Productive Sectors
|
||||
export const SECTOR_PRODUCTIVO_MAP: Record<string, string[]> = {
|
||||
[SECTOR_ECONOMICO.PRIMARIO]: [SECTOR_PRODUCTIVO.AGRICOLA],
|
||||
[SECTOR_ECONOMICO.SECUNDARIO]: [SECTOR_PRODUCTIVO.MANUFACTURA],
|
||||
[SECTOR_ECONOMICO.TERCIARIO]: [
|
||||
SECTOR_PRODUCTIVO.SERVICIOS,
|
||||
SECTOR_PRODUCTIVO.TURISMO,
|
||||
SECTOR_PRODUCTIVO.COMERCIO,
|
||||
],
|
||||
};
|
||||
|
||||
// Map: Productive Sector -> Central Productive Activity
|
||||
export const ACTIVIDAD_CENTRAL_MAP: Record<string, string[]> = {
|
||||
[SECTOR_PRODUCTIVO.AGRICOLA]: [
|
||||
ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL,
|
||||
ACTIVIDAD_CENTRAL.PRODUCCION_ANIMAL,
|
||||
ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL_ANIMAL,
|
||||
],
|
||||
[SECTOR_PRODUCTIVO.MANUFACTURA]: [ACTIVIDAD_CENTRAL.INDUSTRIAL],
|
||||
[SECTOR_PRODUCTIVO.SERVICIOS]: [ACTIVIDAD_CENTRAL.SERVICIOS],
|
||||
[SECTOR_PRODUCTIVO.TURISMO]: [ACTIVIDAD_CENTRAL.TURISMO],
|
||||
[SECTOR_PRODUCTIVO.COMERCIO]: [ACTIVIDAD_CENTRAL.COMERCIO],
|
||||
};
|
||||
|
||||
// Map: Central Productive Activity -> Main Productive Activity
|
||||
export const ACTIVIDAD_PRINCIPAL_MAP: Record<string, string[]> = {
|
||||
[ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL]: [ACTIVIDAD_PRINCIPAL.AGRICULTURA],
|
||||
[ACTIVIDAD_CENTRAL.PRODUCCION_ANIMAL]: [ACTIVIDAD_PRINCIPAL.CRIA],
|
||||
[ACTIVIDAD_CENTRAL.PRODUCCION_VEGETAL_ANIMAL]: [
|
||||
ACTIVIDAD_PRINCIPAL.PATIOS_PRODUCTIVOS,
|
||||
],
|
||||
[ACTIVIDAD_CENTRAL.INDUSTRIAL]: [
|
||||
ACTIVIDAD_PRINCIPAL.TRANSFORMACION_MATERIA,
|
||||
ACTIVIDAD_PRINCIPAL.TEXTIL,
|
||||
ACTIVIDAD_PRINCIPAL.CONSTRUCCION,
|
||||
],
|
||||
[ACTIVIDAD_CENTRAL.SERVICIOS]: [ACTIVIDAD_PRINCIPAL.BIENES_SERVICIOS],
|
||||
[ACTIVIDAD_CENTRAL.TURISMO]: [
|
||||
ACTIVIDAD_PRINCIPAL.VISITAS_GUIADAS,
|
||||
ACTIVIDAD_PRINCIPAL.ALOJAMIENTO,
|
||||
ACTIVIDAD_PRINCIPAL.TURISMO,
|
||||
],
|
||||
[ACTIVIDAD_CENTRAL.COMERCIO]: [ACTIVIDAD_PRINCIPAL.COMERCIO],
|
||||
};
|
||||
|
||||
// Map: Main Productive Activity -> Productive Activity (The long list)
|
||||
export const ACTIVIDAD_PRODUCTIVA_MAP: Record<string, string[]> = {
|
||||
[ACTIVIDAD_PRINCIPAL.AGRICULTURA]: [
|
||||
'SIEMBRA DE MAIZ',
|
||||
'SIEMBRA DE AJI',
|
||||
'SIEMBRA DE CAFÉ',
|
||||
'SIEMBRA DE PLATANO',
|
||||
'SIEMBRA DE CAMBUR',
|
||||
'SIEMBRA DE AGUACATE',
|
||||
'SIEMBRA DE FRUTAS',
|
||||
'SIEMBRA DE HORTALIZAS',
|
||||
'SIEMBRA DE TOMATE',
|
||||
'SIEMBRA DE CACAO',
|
||||
'SIEMBRA DE PIMENTON',
|
||||
'SIEMBRA DE YUCA',
|
||||
'SIEMBRA DE CAÑA DE AZUCAR',
|
||||
'SIEMBRA DE GRANOS (CARAOTAS, FRIJOLES)',
|
||||
'SIEMBRA DE ARROZ',
|
||||
'SIEMBRA DE CEREALES (CEBADA, LINAZA, SOYA)',
|
||||
'ELABORACION DE BIO-INSUMO (ABONO ORGANICO)',
|
||||
'OTRO'
|
||||
],
|
||||
[ACTIVIDAD_PRINCIPAL.CRIA]: [
|
||||
'BOVINO',
|
||||
'PORCINO',
|
||||
'CAPRINO',
|
||||
'CUNICULTURA',
|
||||
'AVICOLA',
|
||||
'PISCICULA',
|
||||
'OTRO'
|
||||
],
|
||||
[ACTIVIDAD_PRINCIPAL.PATIOS_PRODUCTIVOS]: ['SIEMBRA Y CRIA', 'OTRO'],
|
||||
[ACTIVIDAD_PRINCIPAL.TRANSFORMACION_MATERIA]: [
|
||||
'ELABORACION DE PRODUCTOS QUIMICOS (LIMPIEZA E HIGIENE PERSONAL)',
|
||||
'PANADERIAS',
|
||||
'RESPOSTERIA',
|
||||
'ELABORACION DE HARINAS PRECOCIDA',
|
||||
'PLANTA ABA (ELABORACION DE ALIMENTOS BALANCEADOS PARA ANIMALES)',
|
||||
'ELABORACION DE PRODUCTOS DERIVADO DE LA LECHE (VACA, CABRA, BUFFALA)',
|
||||
'EMPAQUETADORAS DE GRANOS Y POLVOS',
|
||||
'ELABORACION DE ACEITE COMESTIBLE',
|
||||
'FABRICA DE HIELO',
|
||||
'ELABORACION DE PAPELON',
|
||||
'TORREFACTORA DE CÁFE',
|
||||
'ESPULPADORA DE TOMATES Y FRUTAS',
|
||||
'ARTESANIAS',
|
||||
'OTRO'
|
||||
],
|
||||
[ACTIVIDAD_PRINCIPAL.TEXTIL]: [
|
||||
'ELABORACION DE UNIFORME ESCOLARES Y PRENDA DE VESTIR',
|
||||
'ELABORACION DE PRENDAS INTIMAS',
|
||||
'ELABORACION DE LENCERIA',
|
||||
'SUBLIMACION DE TEJIDOS',
|
||||
'ELABORACION DE CALZADOS',
|
||||
'OTRO'
|
||||
],
|
||||
[ACTIVIDAD_PRINCIPAL.CONSTRUCCION]: [
|
||||
'BLOQUERAS',
|
||||
'PLANTA PREMEZCLADORA DE CEMENTO',
|
||||
'CARPINTERIAS',
|
||||
'HERRERIAS',
|
||||
'OTRO'
|
||||
],
|
||||
[ACTIVIDAD_PRINCIPAL.BIENES_SERVICIOS]: [
|
||||
'MERCADOS COMUNALES',
|
||||
'CENTROS DE ACOPIOS Y DISTRIBUCION',
|
||||
'UNIDAD DE SUMINISTRO',
|
||||
'MATADERO (SALA DE MATANZA DE ANIMALES)',
|
||||
'PELUQUERIA',
|
||||
'BARBERIA',
|
||||
'AGENCIAS DE FESTEJOS',
|
||||
'LAVANDERIAS',
|
||||
'REPARACION DE CALZADOS',
|
||||
'TALLER DE MECANICA',
|
||||
'TRANSPORTES',
|
||||
'OTRO'
|
||||
],
|
||||
[ACTIVIDAD_PRINCIPAL.VISITAS_GUIADAS]: ['RUTAS TURISTICAS', 'OTRO'],
|
||||
[ACTIVIDAD_PRINCIPAL.ALOJAMIENTO]: ['POSADAS', 'HOTELES', 'OTRO'],
|
||||
[ACTIVIDAD_PRINCIPAL.TURISMO]: ['AGENCIAS DE VIAJES', 'OTRO'],
|
||||
[ACTIVIDAD_PRINCIPAL.COMERCIO]: [
|
||||
'VENTA DE VIVERES',
|
||||
'VENTAS DE PRENDAS DE VESTIR',
|
||||
'VENTA DE PRODUCTOS QUIMICOS Y DERIVADOS',
|
||||
'BODEGAS COMUNALES',
|
||||
'FRIGORIFICOS Y CARNICOS',
|
||||
'OTRO'
|
||||
],
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CreateUser, UpdateUser } from "../schemas/users";
|
||||
import { updateUserAction, createUserAction, deleteUserAction, updateProfileAction } from "../actions/actions";
|
||||
|
||||
// Create mutation
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CreateUser) => createUserAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
// onError: (e) => console.error('Error:', e),
|
||||
})
|
||||
return mutation
|
||||
}
|
||||
|
||||
// Update mutation
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UpdateUser) => updateUserAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
onError: (e) => console.error('Error:', e)
|
||||
})
|
||||
return mutation;
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UpdateUser) => updateProfileAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
// onError: (e) => console.error('Error:', e)
|
||||
})
|
||||
return mutation;
|
||||
}
|
||||
|
||||
// Delete mutation
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteUserAction(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
onError: (e) => console.error('Error:', e)
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,44 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { TrainingSchema } from "../schemas/training";
|
||||
import { createTrainingAction, updateTrainingAction, deleteTrainingAction } from "../actions/training-actions";
|
||||
import { useSafeQuery } from '@/hooks/use-safe-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createTrainingAction,
|
||||
deleteTrainingAction,
|
||||
getTrainingAction,
|
||||
getTrainingByIdAction,
|
||||
updateTrainingAction,
|
||||
} from '../actions/training-actions';
|
||||
import { TrainingSchema } from '../schemas/training';
|
||||
|
||||
export function useTrainingQuery(params = {}) {
|
||||
return useSafeQuery(['training', params], () => getTrainingAction(params));
|
||||
}
|
||||
|
||||
export function useTrainingByIdQuery(id: number) {
|
||||
return useSafeQuery(['training', id], () => getTrainingByIdAction(id));
|
||||
}
|
||||
|
||||
export function useCreateTraining() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: TrainingSchema) => createTrainingAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
|
||||
})
|
||||
return mutation
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: TrainingSchema) => createTrainingAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
|
||||
});
|
||||
return mutation;
|
||||
}
|
||||
|
||||
export function useUpdateTraining() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: TrainingSchema) => updateTrainingAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
|
||||
})
|
||||
return mutation;
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: TrainingSchema) => updateTrainingAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
|
||||
});
|
||||
return mutation;
|
||||
}
|
||||
|
||||
export function useDeleteTraining() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteTrainingAction(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
|
||||
})
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteTrainingAction(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['training'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const statisticsItemSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((val) => val || 'Sin Información'),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
export const trainingStatisticsSchema = z.object({
|
||||
totalOsps: z.number(),
|
||||
totalProducers: z.number(),
|
||||
statusDistribution: z.array(statisticsItemSchema),
|
||||
activityDistribution: z.array(statisticsItemSchema),
|
||||
typeDistribution: z.array(statisticsItemSchema),
|
||||
stateDistribution: z.array(statisticsItemSchema),
|
||||
yearDistribution: z.array(statisticsItemSchema),
|
||||
totalOsps: z.number(),
|
||||
totalProducers: z.number(),
|
||||
totalProducts: z.number(),
|
||||
statusDistribution: z.array(statisticsItemSchema),
|
||||
activityDistribution: z.array(statisticsItemSchema),
|
||||
typeDistribution: z.array(statisticsItemSchema),
|
||||
stateDistribution: z.array(statisticsItemSchema),
|
||||
yearDistribution: z.array(statisticsItemSchema),
|
||||
});
|
||||
|
||||
export type TrainingStatisticsData = z.infer<typeof trainingStatisticsSchema>;
|
||||
|
||||
export const trainingStatisticsResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: trainingStatisticsSchema,
|
||||
message: z.string(),
|
||||
data: trainingStatisticsSchema,
|
||||
});
|
||||
|
||||
@@ -1,61 +1,337 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// 1. Definimos el esquema de un item individual de la lista de productos
|
||||
// Basado en los campos que usaste en ProductActivityList
|
||||
const productItemSchema = z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
dailyCount: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
weeklyCount: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
monthlyCount: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
});
|
||||
|
||||
const productionItemSchema = z.object({
|
||||
supplyType: z.string().optional().nullable(),
|
||||
quantity: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
unit: z.string().min(1, { message: 'Unidad es requerida' }).nullable(),
|
||||
});
|
||||
|
||||
const equipmentItemSchema = z.object({
|
||||
machine: z.string().nullable(),
|
||||
quantity: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
});
|
||||
|
||||
export const trainingSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
firstname: z.string().min(1, { message: "Nombre es requerido" }),
|
||||
lastname: z.string().min(1, { message: "Apellido es requerido" }),
|
||||
visitDate: z.string().or(z.date()).transform((val) => new Date(val).toISOString()),
|
||||
productiveActivity: z.string().min(1, { message: "Actividad productiva es requerida" }),
|
||||
financialRequirementDescription: z.string().min(1, { message: "Descripción es requerida" }),
|
||||
siturCodeCommune: z.string().min(1, { message: "Código SITUR Comuna es requerido" }),
|
||||
communalCouncil: z.string().min(1, { message: "Consejo Comunal es requerido" }),
|
||||
siturCodeCommunalCouncil: z.string().min(1, { message: "Código SITUR Consejo Comunal es requerido" }),
|
||||
ospName: z.string().min(1, { message: "Nombre de la OSP es requerido" }),
|
||||
ospAddress: z.string().min(1, { message: "Dirección de la OSP es requerida" }),
|
||||
ospRif: z.string().min(1, { message: "RIF de la OSP es requerido" }),
|
||||
ospType: z.string().min(1, { message: "Tipo de OSP es requerido" }),
|
||||
currentStatus: z.string().min(1, { message: "Estatus actual es requerido" }),
|
||||
companyConstitutionYear: z.coerce.number().min(1900, { message: "Año inválido" }),
|
||||
producerCount: z.coerce.number().min(1, { message: "Cantidad de productores requerida" }),
|
||||
productDescription: z.string().min(1, { message: "Descripción del producto es requerida" }),
|
||||
installedCapacity: z.string().min(1, { message: "Capacidad instalada es requerida" }),
|
||||
operationalCapacity: z.string().min(1, { message: "Capacidad operativa es requerida" }),
|
||||
ospResponsibleFullname: z.string().min(1, { message: "Nombre del responsable es requerido" }),
|
||||
ospResponsibleCedula: z.string().min(1, { message: "Cédula del responsable es requerida" }),
|
||||
ospResponsibleRif: z.string().min(1, { message: "RIF del responsable es requerido" }),
|
||||
ospResponsiblePhone: z.string().min(1, { message: "Teléfono del responsable es requerido" }),
|
||||
civilState: z.string().min(1, { message: "Estado civil es requerido" }),
|
||||
familyBurden: z.coerce.number().min(0, { message: "Carga familiar requerida" }),
|
||||
numberOfChildren: z.coerce.number().min(0, { message: "Número de hijos requerido" }),
|
||||
ospResponsibleEmail: z.string().email({ message: "Correo electrónico inválido" }),
|
||||
generalObservations: z.string().optional().default(''),
|
||||
photo1: z.string().optional().default(''),
|
||||
photo2: z.string().optional().default(''),
|
||||
photo3: z.string().optional().default(''),
|
||||
paralysisReason: z.string().optional().default(''),
|
||||
state: z.number().optional().nullable(),
|
||||
municipality: z.number().optional().nullable(),
|
||||
parish: z.number().optional().nullable(),
|
||||
//Datos de la visita
|
||||
id: z.number().optional(),
|
||||
coorFullName: z
|
||||
.string()
|
||||
.min(1, { message: 'Nombre del coordinador es requerido' }),
|
||||
coorPhone: z.string().refine((val) => /^(04|02)\d{9}$/.test(val), {
|
||||
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
|
||||
}),
|
||||
visitDate: z
|
||||
.string()
|
||||
.min(1, { message: 'Fecha y hora de visita es requerida' }),
|
||||
|
||||
//Datos de la organización socioproductiva (OSP)
|
||||
ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
|
||||
ecoSector: z.string({ message: 'Sector Económico es requerido' }),
|
||||
productiveSector: z.string({ message: 'Sector Productivo es requerido' }),
|
||||
centralProductiveActivity: z.string({
|
||||
message: 'Actividad Central Productiva es requerido',
|
||||
}),
|
||||
mainProductiveActivity: z.string({
|
||||
message: 'Actividad Productiva Principal es requerida',
|
||||
}),
|
||||
productiveActivity: z.string({
|
||||
message: 'Actividad Productiva es requerida',
|
||||
}),
|
||||
productiveActivityOther: z.string().min(1, { message: 'Este campo es requerido' }).optional(),
|
||||
ospRif: z.string().optional().or(z.literal('')).nullable(),
|
||||
ospName: z.string().optional().or(z.literal('')).nullable(),
|
||||
companyConstitutionYear: z.coerce
|
||||
.number()
|
||||
.min(1900, { message: 'Año inválido' })
|
||||
.nullable(),
|
||||
currentStatus: z
|
||||
.string()
|
||||
.min(1, { message: 'Estatus actual es requerido' })
|
||||
.default('ACTIVA'),
|
||||
infrastructureMt2: z.string({ message: 'Infraestructura es requerida' }),
|
||||
hasTransport: z
|
||||
.preprocess(
|
||||
(val) => val === 'true' || val === true || val === 1 || val === '1',
|
||||
z.boolean(),
|
||||
)
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(false),
|
||||
structureType: z.string({ message: 'Tipo de estructura es requerido' }),
|
||||
isOpenSpace: z
|
||||
.preprocess(
|
||||
(val) => val === 'true' || val === true || val === 1 || val === '1',
|
||||
z.boolean(),
|
||||
)
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(false),
|
||||
paralysisReason: z.string().optional().nullable(),
|
||||
|
||||
//Datos del Equipamiento
|
||||
equipmentList: z.array(equipmentItemSchema).optional().default([]),
|
||||
|
||||
//Datos de Producción
|
||||
productionList: z.array(productionItemSchema).optional().default([]),
|
||||
|
||||
// Datos de Actividad Productiva
|
||||
productList: z.array(productItemSchema).optional().default([]),
|
||||
|
||||
// Distribución y Exportación
|
||||
internalDistributionZone: z
|
||||
.string()
|
||||
.min(1, { message: 'Zona de distribución es requerida' }),
|
||||
isExporting: z
|
||||
.preprocess(
|
||||
(val) => val === 'true' || val === true || val === 1 || val === '1',
|
||||
z.boolean(),
|
||||
)
|
||||
.optional()
|
||||
.default(false),
|
||||
externalCountry: z.string().optional().nullable(),
|
||||
externalCity: z.string().optional().nullable(),
|
||||
externalDescription: z.string().optional().nullable(),
|
||||
externalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
externalUnit: z.string().optional().nullable(),
|
||||
|
||||
// Mano de obra
|
||||
womenCount: z.coerce
|
||||
.number()
|
||||
.min(0, { message: 'Cantidad de mujeres es requerida' }),
|
||||
menCount: z.coerce
|
||||
.number()
|
||||
.min(0, { message: 'Cantidad de hombres es requerida' }),
|
||||
|
||||
//Detalles de la ubicación
|
||||
ospAddress: z
|
||||
.string()
|
||||
.min(1, { message: 'Dirección de la OSP es requerida' }),
|
||||
ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
|
||||
communeName: z
|
||||
.string()
|
||||
.min(1, { message: 'Nombre de la comuna es requerida' }),
|
||||
siturCodeCommune: z
|
||||
.string()
|
||||
.min(1, { message: 'Código SITUR de la comuna es requerida' }),
|
||||
communeRif: z.string().min(1, { message: 'Rif de la comuna es requerida' }),
|
||||
communeSpokespersonName: z
|
||||
.string()
|
||||
.min(1, { message: 'Nombre del vocero de la comuna es requerido' }),
|
||||
communeSpokespersonPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
|
||||
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
|
||||
}),
|
||||
communeEmail: z
|
||||
.string()
|
||||
.email({ message: 'Correo electrónico de la Comuna inválido' })
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.nullable(),
|
||||
communalCouncil: z
|
||||
.string()
|
||||
.min(1, { message: 'Consejo Comunal es requerido' }),
|
||||
siturCodeCommunalCouncil: z
|
||||
.string()
|
||||
.min(1, { message: 'Código SITUR del Consejo Comunal es requerido' }),
|
||||
communalCouncilRif: z
|
||||
.string()
|
||||
.min(1, { message: 'Rif del Consejo Comunal es requerido' }),
|
||||
communalCouncilSpokespersonName: z
|
||||
.string()
|
||||
.min(1, { message: 'Nombre del vocero es requerido' }),
|
||||
communalCouncilSpokespersonPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
|
||||
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
|
||||
}),
|
||||
communalCouncilEmail: z
|
||||
.string()
|
||||
.email({ message: 'Correo electrónico del Consejo Comunal inválido' })
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.nullable(),
|
||||
|
||||
//Datos del Responsable OSP
|
||||
ospResponsibleCedula: z
|
||||
.string()
|
||||
.min(1, { message: 'Cédula del responsable es requerida' }),
|
||||
ospResponsibleFullname: z
|
||||
.string()
|
||||
.min(1, { message: 'Nombre del responsable es requerido' }),
|
||||
ospResponsibleRif: z.string().optional().nullable(),
|
||||
civilState: z.string().optional().nullable(),
|
||||
ospResponsiblePhone: z
|
||||
.string()
|
||||
.min(1, { message: 'Teléfono del responsable es requerido' })
|
||||
.regex(/^(04|02)\d{9}$/, {
|
||||
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
|
||||
}),
|
||||
ospResponsibleEmail: z
|
||||
.string()
|
||||
.email({ message: 'Correo electrónico inválido' })
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.nullable(),
|
||||
|
||||
familyBurden: z.coerce.number().optional(),
|
||||
numberOfChildren: z.coerce.number().optional(),
|
||||
|
||||
//Datos adicionales
|
||||
generalObservations: z.string().optional().nullable(),
|
||||
|
||||
//IMAGENES
|
||||
files: z.any().optional(),
|
||||
|
||||
//no se envia la backend al crear ni editar el formulario
|
||||
state: z.number({ message: 'El estado es requerido' }).nullable(),
|
||||
municipality: z.number({ message: 'Municipio es requerido' }).nullable(),
|
||||
parish: z.number({ message: 'Parroquia es requerido' }).nullable(),
|
||||
coorState: z.number().optional().nullable(),
|
||||
coorMunicipality: z.number().optional().nullable(),
|
||||
coorParish: z.number().optional().nullable(),
|
||||
photo1: z.string().optional().nullable(),
|
||||
photo2: z.string().optional().nullable(),
|
||||
photo3: z.string().optional().nullable(),
|
||||
createdBy: z.number().optional().nullable(),
|
||||
updatedBy: z.number().optional().nullable(),
|
||||
created_at: z.string().optional().nullable(),
|
||||
updated_at: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type TrainingSchema = z.infer<typeof trainingSchema>;
|
||||
|
||||
export const getTrainingSchema = z.object({
|
||||
//Datos de la visita
|
||||
id: z.number().optional(),
|
||||
coorFullName: z.string(),
|
||||
coorPhone: z.string(),
|
||||
visitDate: z.string(),
|
||||
//Datos de la organización socioproductiva (OSP)
|
||||
ospType: z.string(),
|
||||
ecoSector: z.string(),
|
||||
productiveSector: z.string(),
|
||||
centralProductiveActivity: z.string(),
|
||||
mainProductiveActivity: z.string(),
|
||||
productiveActivity: z.string(),
|
||||
productiveActivityOther: z.string(),
|
||||
ospRif: z.string().optional().or(z.literal('')).nullable(),
|
||||
ospName: z.string().optional().or(z.literal('')).nullable(),
|
||||
companyConstitutionYear: z.coerce.number(),
|
||||
currentStatus: z.string(),
|
||||
infrastructureMt2: z.string(),
|
||||
hasTransport: z
|
||||
.preprocess(
|
||||
(val) => val === 'true' || val === true || val === 1 || val === '1',
|
||||
z.boolean(),
|
||||
)
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(false),
|
||||
structureType: z.string(),
|
||||
isOpenSpace: z
|
||||
.preprocess(
|
||||
(val) => val === 'true' || val === true || val === 1 || val === '1',
|
||||
z.boolean(),
|
||||
)
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(false),
|
||||
paralysisReason: z.string().optional().nullable(),
|
||||
//Datos del Equipamiento
|
||||
equipmentList: z.array(equipmentItemSchema).optional().default([]),
|
||||
//Datos de Producción
|
||||
productionList: z.array(productionItemSchema).optional().default([]),
|
||||
// Datos de Actividad Productiva
|
||||
productList: z.array(productItemSchema).optional().default([]),
|
||||
// Distribución y Exportación
|
||||
internalDistributionZone: z.string(),
|
||||
isExporting: z
|
||||
.preprocess(
|
||||
(val) => val === 'true' || val === true || val === 1 || val === '1',
|
||||
z.boolean(),
|
||||
)
|
||||
.optional()
|
||||
.default(false),
|
||||
externalCountry: z.string().optional().nullable(),
|
||||
externalCity: z.string().optional().nullable(),
|
||||
externalDescription: z.string().optional().nullable(),
|
||||
externalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
|
||||
externalUnit: z.string().optional().nullable(),
|
||||
// Mano de obra
|
||||
womenCount: z.coerce.number(),
|
||||
menCount: z.coerce.number(),
|
||||
//Detalles de la ubicación
|
||||
ospAddress: z.string(),
|
||||
ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
|
||||
communeName: z.string(),
|
||||
siturCodeCommune: z.string(),
|
||||
communeRif: z.string().or(z.literal('')).nullable(),
|
||||
communeSpokespersonName: z.string().or(z.literal('')).nullable(),
|
||||
communeSpokespersonPhone: z.string(),
|
||||
communeEmail: z.string().optional().or(z.literal('')).nullable(),
|
||||
communalCouncil: z.string(),
|
||||
siturCodeCommunalCouncil: z.string(),
|
||||
communalCouncilRif: z.string().optional(),
|
||||
communalCouncilSpokespersonName: z.string(),
|
||||
communalCouncilSpokespersonPhone: z.string(),
|
||||
communalCouncilEmail: z.string(),
|
||||
//Datos del Responsable OSP
|
||||
ospResponsibleCedula: z.string(),
|
||||
ospResponsibleFullname: z.string(),
|
||||
ospResponsibleRif: z.string().optional().nullable(),
|
||||
civilState: z.string().optional().nullable(),
|
||||
ospResponsiblePhone: z.string(),
|
||||
ospResponsibleEmail: z.string(),
|
||||
familyBurden: z.coerce.number().optional(),
|
||||
numberOfChildren: z.coerce.number().optional(),
|
||||
//Datos adicionales
|
||||
generalObservations: z.string().optional().nullable(),
|
||||
//no se envia la backend al crear ni editar el formulario
|
||||
state: z.number().nullable(),
|
||||
municipality: z.number().nullable(),
|
||||
parish: z.number().nullable(),
|
||||
coorState: z.number().optional().nullable(),
|
||||
coorMunicipality: z.number().optional().nullable(),
|
||||
coorParish: z.number().optional().nullable(),
|
||||
photo1: z.string().optional().nullable(),
|
||||
photo2: z.string().optional().nullable(),
|
||||
photo3: z.string().optional().nullable(),
|
||||
createdBy: z.number().optional().nullable(),
|
||||
updatedBy: z.number().optional().nullable(),
|
||||
created_at: z.string().optional().nullable(),
|
||||
updated_at: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const trainingApiResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(trainingSchema),
|
||||
meta: z.object({
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
totalCount: z.number(),
|
||||
totalPages: z.number(),
|
||||
hasNextPage: z.boolean(),
|
||||
hasPreviousPage: z.boolean(),
|
||||
nextPage: z.number().nullable(),
|
||||
previousPage: z.number().nullable(),
|
||||
}),
|
||||
message: z.string(),
|
||||
data: z.array(getTrainingSchema),
|
||||
meta: z.object({
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
totalCount: z.number(),
|
||||
totalPages: z.number(),
|
||||
hasNextPage: z.boolean(),
|
||||
hasPreviousPage: z.boolean(),
|
||||
nextPage: z.number().nullable(),
|
||||
previousPage: z.number().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TrainingMutate = z.object({
|
||||
message: z.string(),
|
||||
data: trainingSchema,
|
||||
message: z.string(),
|
||||
data: trainingSchema,
|
||||
});
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import {
|
||||
surveysApiResponseSchema,
|
||||
CreateUser,
|
||||
surveysApiResponseSchema,
|
||||
UpdateUser,
|
||||
UsersMutate,
|
||||
UpdateUser
|
||||
} from '../schemas/users';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
|
||||
export const getProfileAction = async () => {
|
||||
const session = await auth()
|
||||
const id = session?.user?.id
|
||||
const session = await auth();
|
||||
const id = session?.user?.id;
|
||||
|
||||
const [error, response] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
`/users/${id}`,
|
||||
'GET'
|
||||
'GET',
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
return response;
|
||||
@@ -33,7 +32,6 @@ export const updateProfileAction = async (payload: UpdateUser) => {
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
console.log(payload);
|
||||
if (error) {
|
||||
if (error.message === 'Email already exists') {
|
||||
throw new Error('Ese correo ya está en uso');
|
||||
@@ -51,7 +49,6 @@ export const getUsersAction = async (params: {
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}) => {
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: (params.page || 1).toString(),
|
||||
limit: (params.limit || 10).toString(),
|
||||
@@ -83,7 +80,7 @@ export const getUsersAction = async (params: {
|
||||
previousPage: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const createUserAction = async (payload: CreateUser) => {
|
||||
const { id, confirmPassword, ...payloadWithoutId } = payload;
|
||||
@@ -130,19 +127,14 @@ export const updateUserAction = async (payload: UpdateUser) => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUserAction = async (id: Number) => {
|
||||
const [error] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
`/users/${id}`,
|
||||
'DELETE'
|
||||
)
|
||||
const [error] = await safeFetchApi(UsersMutate, `/users/${id}`, 'DELETE');
|
||||
|
||||
console.log(error);
|
||||
|
||||
|
||||
// if (error) throw new Error(error.message || 'Error al eliminar el usuario')
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@repo/shadcn/select';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useCreateUser } from "../../hooks/use-mutation-users";
|
||||
import { useCreateUser } from '../../hooks/use-mutation-users';
|
||||
import { CreateUser, createUser } from '../../schemas/users';
|
||||
|
||||
const ROLES = {
|
||||
@@ -29,8 +29,9 @@ const ROLES = {
|
||||
4: 'Gerente',
|
||||
5: 'Usuario',
|
||||
6: 'Productor',
|
||||
7: 'Organización'
|
||||
}
|
||||
7: 'Organización',
|
||||
8: 'Coordinadores',
|
||||
};
|
||||
|
||||
interface CreateUserFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -60,7 +61,7 @@ export function CreateUserForm({
|
||||
id: defaultValues?.id,
|
||||
phone: defaultValues?.phone || '',
|
||||
role: undefined,
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<CreateUser>({
|
||||
resolver: zodResolver(createUser),
|
||||
@@ -69,8 +70,6 @@ export function CreateUserForm({
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: CreateUser) => {
|
||||
console.log(formData);
|
||||
|
||||
saveAccountingAccounts(formData, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
@@ -143,7 +142,7 @@ export function CreateUserForm({
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value?.toString() ?? ''}/>
|
||||
<Input {...field} value={field.value?.toString() ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -157,7 +156,7 @@ export function CreateUserForm({
|
||||
<FormItem>
|
||||
<FormLabel>Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -166,12 +165,12 @@ export function CreateUserForm({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirmar Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -184,7 +183,9 @@ export function CreateUserForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Rol</FormLabel>
|
||||
<Select onValueChange={(value) => field.onChange(Number(value))}>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useUpdateUser } from '@/feactures/users/hooks/use-mutation-users';
|
||||
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
@@ -19,8 +21,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@repo/shadcn/select';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useUpdateUser } from "@/feactures/users/hooks/use-mutation-users";
|
||||
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
|
||||
|
||||
const ROLES = {
|
||||
// 1: 'Superadmin',
|
||||
@@ -29,8 +29,9 @@ const ROLES = {
|
||||
4: 'Gerente',
|
||||
5: 'Usuario',
|
||||
6: 'Productor',
|
||||
7: 'Organización'
|
||||
}
|
||||
7: 'Organización',
|
||||
8: 'Coordinadores',
|
||||
};
|
||||
|
||||
interface UserFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -57,8 +58,8 @@ export function UpdateUserForm({
|
||||
id: defaultValues?.id,
|
||||
phone: defaultValues?.phone || '',
|
||||
role: undefined,
|
||||
isActive: defaultValues?.isActive
|
||||
}
|
||||
isActive: defaultValues?.isActive,
|
||||
};
|
||||
|
||||
// console.log(defaultValues);
|
||||
|
||||
@@ -69,8 +70,7 @@ export function UpdateUserForm({
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UpdateUser) => {
|
||||
|
||||
const formData = data
|
||||
const formData = data;
|
||||
|
||||
saveAccountingAccounts(formData, {
|
||||
onSuccess: () => {
|
||||
@@ -144,7 +144,7 @@ export function UpdateUserForm({
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value?.toString() ?? ''}/>
|
||||
<Input {...field} value={field.value?.toString() ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -153,12 +153,12 @@ export function UpdateUserForm({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nueva Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -171,7 +171,9 @@ export function UpdateUserForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Rol</FormLabel>
|
||||
<Select onValueChange={(value) => field.onChange(Number(value))}>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
@@ -196,7 +198,10 @@ export function UpdateUserForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Estatus</FormLabel>
|
||||
<Select defaultValue={String(field.value)} onValueChange={(value) => field.onChange(Boolean(value))}>
|
||||
<Select
|
||||
defaultValue={String(field.value)}
|
||||
onValueChange={(value) => field.onChange(Boolean(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Seleccione un estatus" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -5,12 +5,24 @@ import { Edit2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AccountPlanModal } from './modal-profile';
|
||||
|
||||
const ROLE_TRANSLATIONS: Record<string, string> = {
|
||||
superadmin: 'Superadmin',
|
||||
admin: 'Administrador',
|
||||
autoridad: 'Autoridad',
|
||||
manager: 'Gerente',
|
||||
user: 'Usuario',
|
||||
producers: 'Productor',
|
||||
organization: 'Organización',
|
||||
coordinators: 'Coordinador',
|
||||
};
|
||||
|
||||
export function Profile() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = useUserByProfile();
|
||||
|
||||
// console.log("🎯 data:", data);
|
||||
const userRole = data?.data.role as string;
|
||||
const translatedRole = ROLE_TRANSLATIONS[userRole] || userRole || 'Sin Rol';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -18,58 +30,60 @@ export function Profile() {
|
||||
<Edit2 className="mr-2 h-4 w-4" /> Editar Perfil
|
||||
</Button>
|
||||
|
||||
<AccountPlanModal open={open} onOpenChange={setOpen} defaultValues={data?.data}/>
|
||||
|
||||
<h2 className='mt-3 mb-1'>Datos del usuario</h2>
|
||||
<AccountPlanModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
defaultValues={data?.data}
|
||||
/>
|
||||
|
||||
<h2 className="mt-3 mb-1">Datos del usuario</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 ">
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Usuario:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Usuario:</p>
|
||||
<p>{data?.data.username || 'Sin Nombre de Usuario'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Rol:</p>
|
||||
<p>{data?.data.role || 'Sin Rol'}</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Rol:</p>
|
||||
<p>{translatedRole}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 className='mt-3 mb-1'>Información personal</h2>
|
||||
|
||||
<h2 className="mt-3 mb-1">Información personal</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Nombre completo:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Nombre completo:</p>
|
||||
<p>{data?.data.fullname || 'Sin nombre y apellido'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Correo:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Correo:</p>
|
||||
<p>{data?.data.email || 'Sin correo'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Teléfono:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Teléfono:</p>
|
||||
<p>{data?.data.phone || 'Sin teléfono'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 className='mt-3 mb-1'>Información de ubicación</h2>
|
||||
<h2 className="mt-3 mb-1">Información de ubicación</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Estado:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Estado:</p>
|
||||
<p>{data?.data.state || 'Sin Estado'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Municipio:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Municipio:</p>
|
||||
<p>{data?.data.municipality || 'Sin Municipio'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Parroquia:</p>
|
||||
<section className="border bg-neutral-200 dark:bg-neutral-800 p-2 rounded-md">
|
||||
<p className="font-bold text-lg">Parroquia:</p>
|
||||
<p>{data?.data.parish || 'Sin Parroquia'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
74
apps/web/lib/auth-token.ts
Normal file
74
apps/web/lib/auth-token.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { resfreshTokenAction } from '@/feactures/auth/actions/refresh-token-action';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { cache } from 'react';
|
||||
|
||||
export const getValidAccessToken = cache(async () => {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.access_token) {
|
||||
// console.log('No hay Access Token');
|
||||
return null
|
||||
}
|
||||
// console.log('Si hay Access Token');
|
||||
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Restamos 10s para tener margen de seguridad
|
||||
const isValid = (session.access_expire_in as number) - 10 > now;
|
||||
|
||||
// A. Si es válido, lo retornamos directo
|
||||
if (isValid) return session.access_token;
|
||||
// console.log('Access Token Expiró');
|
||||
|
||||
// B. Si expiró, buscamos la cookie
|
||||
const cookieStore = cookies();
|
||||
const cookie = await cookieStore
|
||||
const refreshToken = cookie.get('refresh_token')?.value;
|
||||
const teaToken = cookie.get('tea_token')?.value;
|
||||
|
||||
if (!refreshToken) {
|
||||
// console.log('No hay Refresh Token');
|
||||
// Si no hay refres pero si access token pero ya expiro borrar la cookie para forzar cierre de session
|
||||
(await cookieStore).delete('authjs.session-token');// comentar si por algun motivo da error
|
||||
return null
|
||||
} // No hay refresh token, fin del juego
|
||||
// console.log('Si hay Refresh Token');
|
||||
|
||||
if (teaToken) {
|
||||
return teaToken
|
||||
}
|
||||
|
||||
// C. Intentamos refrescar
|
||||
const newTokens = await resfreshTokenAction({ refreshToken });
|
||||
|
||||
if (!newTokens) {
|
||||
// console.log('No hay token nuevo');
|
||||
// Si falla el refresh (token revocado o expirado), borramos cookies
|
||||
(await cookieStore).delete('refresh_token');
|
||||
(await cookieStore).delete('authjs.session-token');// comentar si por algun motivo da error
|
||||
return null;
|
||||
}
|
||||
// console.log('Si hay token nuevo');
|
||||
|
||||
// console.log('Guardamos refresh');
|
||||
// D. Guardamos el nuevo refresh token en cookie y retornamos el access token
|
||||
(await cookieStore).set('refresh_token', newTokens.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
// console.log('guardamo tea');
|
||||
(await cookieStore).set('tea_token', newTokens.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return newTokens.access_token;
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
// lib/auth.config.ts
|
||||
import { SignInAction } from '@/feactures/auth/actions/login-action';
|
||||
import { resfreshTokenAction } from '@/feactures/auth/actions/refresh-token-action';
|
||||
import { logoutAction } from '@/feactures/auth/actions/logout-action';
|
||||
import { CredentialsSignin, NextAuthConfig, Session, User } from 'next-auth';
|
||||
import { DefaultJWT } from 'next-auth/jwt';
|
||||
import { DefaultJWT, JWT } from 'next-auth/jwt';
|
||||
import CredentialProvider from 'next-auth/providers/credentials';
|
||||
|
||||
|
||||
// Define los tipos para tus respuestas de SignInAction
|
||||
interface SignInSuccessResponse {
|
||||
message: string;
|
||||
@@ -58,8 +57,10 @@ const authConfig: NextAuthConfig = {
|
||||
|
||||
// **NUEVO: Manejar el caso `null` primero**
|
||||
if (response === null) {
|
||||
console.error("SignInAction returned null, indicating a potential issue before API call or generic error.");
|
||||
throw new CredentialsSignin("Error de inicio de sesión inesperado.");
|
||||
console.error(
|
||||
'SignInAction returned null, indicating a potential issue before API call or generic error.',
|
||||
);
|
||||
throw new CredentialsSignin('Error de inicio de sesión inesperado.');
|
||||
}
|
||||
|
||||
// Tipo Guarda: Verificar la respuesta de error
|
||||
@@ -70,15 +71,19 @@ const authConfig: NextAuthConfig = {
|
||||
response.type === 'UNKNOWN_ERROR') // Incluye todos los tipos de error posibles
|
||||
) {
|
||||
// Si es un error, lánzalo. Este camino termina aquí.
|
||||
throw new CredentialsSignin("Error en la API:" + response.message);
|
||||
throw new CredentialsSignin('Error en la API:' + response.message);
|
||||
}
|
||||
|
||||
if (!('user' in response)) {
|
||||
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null,
|
||||
// no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
|
||||
// Es un caso de respuesta inesperada del API.
|
||||
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.");
|
||||
throw new CredentialsSignin("Error en el formato de la respuesta del servidor.");
|
||||
console.error(
|
||||
"Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.",
|
||||
);
|
||||
throw new CredentialsSignin(
|
||||
'Error en el formato de la respuesta del servidor.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -89,11 +94,7 @@ const authConfig: NextAuthConfig = {
|
||||
role: response?.user.rol ?? [], // Add role array
|
||||
access_token: response?.tokens.access_token ?? '',
|
||||
access_expire_in: response?.tokens.access_expire_in ?? 0,
|
||||
refresh_token: response?.tokens.refresh_token ?? '',
|
||||
refresh_expire_in: response?.tokens.refresh_expire_in ?? 0,
|
||||
};
|
||||
|
||||
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -101,11 +102,7 @@ const authConfig: NextAuthConfig = {
|
||||
signIn: '/', //sigin page
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }:{
|
||||
user: User
|
||||
token: any
|
||||
|
||||
}) {
|
||||
async jwt({ token, user }: { user: User; token: any }) {
|
||||
// 1. Manejar el inicio de sesión inicial
|
||||
// El `user` solo se proporciona en el primer inicio de sesión.
|
||||
if (user) {
|
||||
@@ -117,68 +114,14 @@ const authConfig: NextAuthConfig = {
|
||||
role: user.role,
|
||||
access_token: user.access_token,
|
||||
access_expire_in: user.access_expire_in,
|
||||
refresh_token: user.refresh_token,
|
||||
refresh_expire_in: user.refresh_expire_in
|
||||
}
|
||||
// return token;
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Si no es un nuevo login, verificar la expiración del token
|
||||
const now = Math.floor(Date.now() / 1000); // Usar Math.floor para un número entero
|
||||
|
||||
// Verificar si el token de acceso aún es válido
|
||||
if (now < (token.access_expire_in as number)) {
|
||||
return token; // Si no ha expirado, no hacer nada y devolver el token actual
|
||||
}
|
||||
|
||||
// console.log("Now Access Expire:",token.access_expire_in);
|
||||
|
||||
|
||||
// 3. Si el token de acceso ha expirado, verificar el refresh token
|
||||
// console.log("Access token ha expirado. Verificando refresh token...");
|
||||
if (now > (token.refresh_expire_in as number)) {
|
||||
// console.log("Refresh token ha expirado. Forzando logout.");
|
||||
return null; // Forzar el logout al devolver null
|
||||
}
|
||||
|
||||
// console.log("token:", token.refresh_token);
|
||||
|
||||
|
||||
// 4. Si el token de acceso ha expirado pero el refresh token es válido, renovar
|
||||
// console.log("Renovando token de acceso...");
|
||||
try {
|
||||
const refresh_token = { token: token.refresh_token as string, user_id: Number(token.id) as number}
|
||||
console.log(refresh_token);
|
||||
|
||||
const res = await resfreshTokenAction(refresh_token);
|
||||
|
||||
if (!res || !res.tokens) {
|
||||
throw new Error('Fallo en la respuesta de la API de refresco.');
|
||||
}
|
||||
|
||||
// console.log("Old Access Expire:", token.access_expire_in);
|
||||
// console.log("New Access Expire:", res.tokens.access_expire_in);
|
||||
|
||||
// console.log("token:", token.refresh_token);
|
||||
|
||||
|
||||
// Actualizar el token directamente con los nuevos valores
|
||||
token.access_token = res.tokens.access_token;
|
||||
token.access_expire_in = res.tokens.access_expire_in;
|
||||
token.refresh_token = res.tokens.refresh_token;
|
||||
token.refresh_expire_in = res.tokens.refresh_expire_in;
|
||||
return token;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error al renovar el token: ", error);
|
||||
return null; // Fallo al renovar, forzar logout
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: any }) {
|
||||
async session({ session, token }: { session: Session; token: DefaultJWT }) {
|
||||
session.access_token = token.access_token as string;
|
||||
session.access_expire_in = token.access_expire_in as number;
|
||||
session.refresh_token = token.refresh_token as string;
|
||||
session.refresh_expire_in = token.refresh_expire_in as number;
|
||||
session.user = {
|
||||
id: token.id as number,
|
||||
username: token.username as string,
|
||||
@@ -189,7 +132,18 @@ const authConfig: NextAuthConfig = {
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
async signOut(message) {
|
||||
// 1. verificamos que venga token (puede no venir con algunos providers)
|
||||
const token = (message as { token?: JWT }).token;
|
||||
if (!token?.access_token) return;
|
||||
try {
|
||||
await logoutAction(String(token?.id));
|
||||
} catch {
|
||||
/* silencioso para que next-auth siempre cierre */
|
||||
}
|
||||
},
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
export default authConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server';
|
||||
import { env } from '@/lib/env';
|
||||
import axios from 'axios';
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Crear instancia de Axios con la URL base validada
|
||||
@@ -10,33 +10,21 @@ const fetchApi = axios.create({
|
||||
|
||||
// Interceptor para incluir el token automáticamente en las peticiones
|
||||
// ESTE INTERCEPTOR ESTÁ BIEN PARA EL RESTO DE LAS PETICIONES AUTENTICADAS
|
||||
fetchApi.interceptors.request.use(async (config: any) => {
|
||||
try {
|
||||
// console.log("Solicitando autenticación...");
|
||||
|
||||
const { auth } = await import('@/lib/auth'); // Importación dinámica
|
||||
const session = await auth();
|
||||
const token = session?.access_token;
|
||||
fetchApi.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
try {
|
||||
const { getValidAccessToken } = await import('@/lib/auth-token');
|
||||
const token = await getValidAccessToken();
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
if (token) {
|
||||
config.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting auth token:', err);
|
||||
}
|
||||
|
||||
// **Importante:** Si el body es FormData, elimina el Content-Type para que Axios lo configure automáticamente.
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
} else {
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error('Error al obtener el token de autenticación para el interceptor:', error);
|
||||
// IMPORTANTE: Si ocurre un error aquí, es mejor rechazar la promesa
|
||||
// para que la solicitud no se envíe sin autorización.
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// safeFetchApi sigue siendo útil para el resto de las llamadas que requieren autenticación
|
||||
export const safeFetchApi = async <T extends z.ZodSchema<any>>(
|
||||
@@ -97,4 +85,4 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
|
||||
}
|
||||
};
|
||||
|
||||
export { fetchApi };
|
||||
export { fetchApi };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user