18 Commits

Author SHA1 Message Date
00ab65aee3 cambios en thorttler 2026-03-24 12:06:23 -04:00
524869b1f9 se agrego tea token. reemplaso del access token expirado. Para evitar refresh en cada consulta 2026-03-23 10:58:59 -04:00
f88ab2a971 Cambios en refresh token para no dar error. No actualiza access token de la cookie/session. Elimina access token de la cookie para forzar cerrar la session en caso de error 2026-03-23 10:20:48 -04:00
0666877811 correcciones tipado web para produccion 2026-03-06 13:23:53 -04:00
ff46776e4a corregido schema training quitado opcional del formulario varios campos 2026-03-06 10:50:19 -04:00
d6de7527e4 cambios en el formulario osp: rol autoridad, campos responsable cambiados, esquema cambiado, añadida columna fecha de creacion a la tabla 2026-03-04 15:07:31 -04:00
f910aea3cc nuevas correciones al formulario y esquema base de datos para osp 2026-02-25 12:17:33 -04:00
a88cf94adb cambio estilos y colores interfaz web 2026-02-24 11:43:45 -04:00
70e5200549 correcion de tipado en equipament_list.tsx 2026-02-24 11:14:04 -04:00
c70e146ce2 anexado guardar en minio y cambios generales en la interfaz de osp 2026-02-24 11:00:50 -04:00
fed90d9ff1 primeros cambios en el formulario osp 2026-02-23 21:15:29 -04:00
0efd5a11bd cambios en la interfaz de organizaciones 2026-02-23 12:40:30 -04:00
e149500735 Merge branch 'main' of https://git.fondemi.gob.ve/Fondemi/sistema_base
cambios en como se guardan los datos
2026-02-23 10:57:56 -04:00
d71ad98e85 ajustes al formulario de organizaciones 2026-02-23 10:57:45 -04:00
590f62fad9 Se guarda el id del usuario que creo el osp y el que lo actualice 2026-02-23 10:15:24 -04:00
510327de58 Cambio de estilo al tema claro/oscuro 2026-02-12 12:47:17 -04:00
a0c363dd1b corregido guardar registro osp 2026-02-11 11:30:22 -04:00
42e802f8a7 corregido refreshtoken y mejorado ver informacion ui por roles 2026-02-10 21:45:34 -04:00
65 changed files with 21591 additions and 1737 deletions

View File

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

View File

@@ -44,6 +44,7 @@
"drizzle-orm": "0.40.0", "drizzle-orm": "0.40.0",
"express": "5.1.0", "express": "5.1.0",
"joi": "17.13.3", "joi": "17.13.3",
"minio": "^8.0.6",
"moment": "2.30.1", "moment": "2.30.1",
"path-to-regexp": "8.2.0", "path-to-regexp": "8.2.0",
"pg": "8.13.3", "pg": "8.13.3",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -120,6 +120,69 @@
"when": 1769653021994, "when": 1769653021994,
"tag": "0016_silent_tag", "tag": "0016_silent_tag",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1770774052351,
"tag": "0017_mute_mole_man",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1771855467870,
"tag": "0018_milky_prism",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1771858973096,
"tag": "0019_cuddly_cobalt_man",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1771897944334,
"tag": "0020_certain_bushwacker",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1771901546945,
"tag": "0021_warm_machine_man",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1772031518006,
"tag": "0022_nervous_dragon_lord",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1772032122473,
"tag": "0023_sticky_slayback",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1772642460042,
"tag": "0024_petite_sabra",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1772643066120,
"tag": "0025_funny_makkari",
"breakpoints": true
} }
] ]
} }

View File

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

View File

@@ -48,8 +48,7 @@ export const trainingSurveys = t.pgTable(
{ {
// === 1. IDENTIFICADORES Y DATOS DE VISITA === // === 1. IDENTIFICADORES Y DATOS DE VISITA ===
id: t.serial('id').primaryKey(), id: t.serial('id').primaryKey(),
firstname: t.text('firstname').notNull(), coorFullName: t.text('coor_full_name').notNull(),
lastname: t.text('lastname').notNull(),
visitDate: t.timestamp('visit_date').notNull(), visitDate: t.timestamp('visit_date').notNull(),
coorPhone: t.text('coor_phone'), coorPhone: t.text('coor_phone'),
@@ -77,8 +76,8 @@ export const trainingSurveys = t.pgTable(
.notNull() .notNull()
.default(''), .default(''),
productiveActivity: t.text('productive_activity').notNull(), productiveActivity: t.text('productive_activity').notNull(),
ospRif: t.text('osp_rif').notNull(), ospRif: t.text('osp_rif'),
ospName: t.text('osp_name').notNull(), ospName: t.text('osp_name'),
companyConstitutionYear: t.integer('company_constitution_year').notNull(), companyConstitutionYear: t.integer('company_constitution_year').notNull(),
currentStatus: t.text('current_status').notNull().default('ACTIVA'), currentStatus: t.text('current_status').notNull().default('ACTIVA'),
infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''), infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''),
@@ -98,19 +97,13 @@ export const trainingSurveys = t.pgTable(
.text('commune_spokesperson_name') .text('commune_spokesperson_name')
.notNull() .notNull()
.default(''), .default(''),
communeSpokespersonCedula: t communeSpokespersonCedula: t.text('commune_spokesperson_cedula'),
.text('commune_spokesperson_cedula') communeSpokespersonRif: t.text('commune_spokesperson_rif'),
.notNull()
.default(''),
communeSpokespersonRif: t
.text('commune_spokesperson_rif')
.notNull()
.default(''),
communeSpokespersonPhone: t communeSpokespersonPhone: t
.text('commune_spokesperson_phone') .text('commune_spokesperson_phone')
.notNull() .notNull()
.default(''), .default(''),
communeEmail: t.text('commune_email').notNull().default(''), communeEmail: t.text('commune_email'),
communalCouncil: t.text('communal_council').notNull(), communalCouncil: t.text('communal_council').notNull(),
siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(), siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(),
communalCouncilRif: t.text('communal_council_rif').notNull().default(''), communalCouncilRif: t.text('communal_council_rif').notNull().default(''),
@@ -118,14 +111,10 @@ export const trainingSurveys = t.pgTable(
.text('communal_council_spokesperson_name') .text('communal_council_spokesperson_name')
.notNull() .notNull()
.default(''), .default(''),
communalCouncilSpokespersonCedula: t communalCouncilSpokespersonCedula: t.text(
.text('communal_council_spokesperson_cedula') 'communal_council_spokesperson_cedula',
.notNull() ),
.default(''), communalCouncilSpokespersonRif: t.text('communal_council_spokesperson_rif'),
communalCouncilSpokespersonRif: t
.text('communal_council_spokesperson_rif')
.notNull()
.default(''),
communalCouncilSpokespersonPhone: t communalCouncilSpokespersonPhone: t
.text('communal_council_spokesperson_phone') .text('communal_council_spokesperson_phone')
.notNull() .notNull()
@@ -136,22 +125,44 @@ export const trainingSurveys = t.pgTable(
.default(''), .default(''),
ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(), ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(),
ospResponsibleCedula: t.text('osp_responsible_cedula').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').notNull(), civilState: t.text('civil_state'),
ospResponsiblePhone: t.text('osp_responsible_phone').notNull(), ospResponsiblePhone: t.text('osp_responsible_phone').notNull(),
ospResponsibleEmail: t.text('osp_responsible_email').notNull(), ospResponsibleEmail: t.text('osp_responsible_email'),
familyBurden: t.integer('family_burden').notNull(), familyBurden: t.integer('family_burden'),
numberOfChildren: t.integer('number_of_children').notNull(), numberOfChildren: t.integer('number_of_children'),
generalObservations: t.text('general_observations'), 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'), photo1: t.text('photo1'),
photo2: t.text('photo2'), photo2: t.text('photo2'),
photo3: t.text('photo3'), 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, ...timestamps,
}, },
(trainingSurveys) => ({ (trainingSurveys) => ({
trainingSurveysIndex: t trainingSurveysIndex: t
.index('training_surveys_index_00') .index('training_surveys_index_00')
.on(trainingSurveys.firstname), .on(trainingSurveys.coorFullName),
}), }),
); );

View File

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

View File

@@ -27,7 +27,7 @@ import { ConfigService } from '@nestjs/config';
import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { and, eq, or } from 'drizzle-orm'; import { eq, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index'; import * as schema from 'src/database/index';
import { roles, sessions, users, usersRole } from 'src/database/index'; import { roles, sessions, users, usersRole } from 'src/database/index';
@@ -273,50 +273,118 @@ export class AuthService {
//Refresh User Access Token //Refresh User Access Token
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> { async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
const secret = envs.refresh_token_secret; const { refreshToken } = dto;
const { user_id, token } = dto;
console.log('secret', secret); // 1. Validar firma del token (Crypto check)
console.log('refresh_token', token); let payload: any;
try {
const validation = await this.jwtService.verifyAsync(token, { payload = await this.jwtService.verifyAsync(refreshToken, {
secret, 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() .select()
.from(sessions) .from(sessions)
.where( .where(eq(sessions.userId, userId));
and(eq(sessions.userId, user_id), eq(sessions.sessionToken, token)),
);
// console.log(session.length); if (!currentSession) throw new NotFoundException('Session not found');
if (session.length === 0) throw new NotFoundException('session not found'); // CONFIGURACIÓN: Tiempo de gracia en milisegundos (ej: 15 segundos)
const user = await this.findUserById(user_id); 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'); if (!user) throw new NotFoundException('User not found');
// Genera token // Generar nuevos tokens (A -> B)
const tokens = await this.generateTokens(user); const tokensNew = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token); const decodeAccess = this.decodeToken(tokensNew.access_token);
const decodeRefresh = this.decodeToken(tokens.refresh_token); const decodeRefresh = this.decodeToken(tokensNew.refresh_token);
// Actualiza session // Actualizamos DB guardando el token "viejo" como "previous"
await this.drizzle await this.drizzle
.update(sessions) .update(sessions)
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp }) .set({
.where(eq(sessions.userId, user_id)); 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 { return {
access_token: tokens.access_token, access_token: tokensNew.access_token,
access_expire_in: decodeAccess.exp, access_expire_in: decodeAccess.exp,
refresh_token: tokens.refresh_token, refresh_token: tokensNew.refresh_token,
refresh_expire_in: decodeRefresh.exp, refresh_expire_in: decodeRefresh.exp,
}; };
} }
// -------------------------------------------------------------------
// ESCENARIO B: Periodo de Gracia (Condición de Carrera)
// -------------------------------------------------------------------
// El token no coincide con el actual, ¿pero coincide con el anterior?
const isPreviousToken =
currentSession.previousSessionToken === refreshToken;
// Calculamos cuánto tiempo ha pasado desde la rotación
const timeSinceRotation = currentSession.lastRotatedAt
? Date.now() - new Date(currentSession.lastRotatedAt).getTime()
: Infinity;
if (isPreviousToken && timeSinceRotation < GRACE_PERIOD_MS) {
// ¡Es una condición de carrera! El usuario envió 'A' pero ya rotamos a 'B'.
// Le devolvemos 'B' (el actual en DB) para que se sincronice.
const user = await this.findUserById(userId);
if (!user) throw new NotFoundException('User not found');
// Generamos un access token nuevo fresco (barato)
const accessTokenPayload = { sub: user.id, username: user.username };
const newAccessToken = await this.jwtService.signAsync(
accessTokenPayload,
{
secret: envs.access_token_secret,
expiresIn: envs.access_token_expiration,
} as JwtSignOptions,
);
const decodeAccess = this.decodeToken(newAccessToken);
// IMPORTANTE: Devolvemos el refresh token QUE YA ESTÁ EN LA BASE DE DATOS
// No generamos uno nuevo para no romper la cadena de la otra petición que ganó.
return {
access_token: newAccessToken,
access_expire_in: decodeAccess.exp,
refresh_token: currentSession.sessionToken!, // Devolvemos el token 'B'
refresh_expire_in: currentSession.expiresAt as number,
};
}
// -------------------------------------------------------------------
// ESCENARIO C: Robo de Token (Reuse Detection)
// -------------------------------------------------------------------
// Si el token no es el actual, ni el anterior válido... ALGUIEN LO ROBÓ.
// O el usuario está intentando reusar un token muy viejo.
// Medida de seguridad: Borrar todas las sesiones del usuario
await this.drizzle.delete(sessions).where(eq(sessions.userId, userId));
throw new UnauthorizedException(
'Refresh token reuse detected. Access revoked.',
);
}
async singUp(createUserDto: SingUpUserDto): Promise<User> { async singUp(createUserDto: SingUpUserDto): Promise<User> {
// Check if username or email exists // Check if username or email exists
const data = await this.drizzle const data = await this.drizzle

View File

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

View File

@@ -2,22 +2,19 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean,
IsDateString, IsDateString,
IsEmail,
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
export class CreateTrainingDto { export class CreateTrainingDto {
// === 1. DATOS BÁSICOS === // === 1. DATOS BÁSICOS ===
@ApiProperty() @ApiProperty()
@IsString() @IsString()
firstname: string; coorFullName: string;
@ApiProperty()
@IsString()
lastname: string;
@ApiProperty() @ApiProperty()
@IsDateString() @IsDateString()
@@ -30,11 +27,11 @@ export class CreateTrainingDto {
// === 2. DATOS OSP === // === 2. DATOS OSP ===
@ApiProperty() @ApiProperty()
@IsString() @IsOptional()
ospName: string; ospName: string;
@ApiProperty() @ApiProperty()
@IsString() @IsOptional()
ospRif: string; ospRif: string;
@ApiProperty() @ApiProperty()
@@ -75,16 +72,14 @@ export class CreateTrainingDto {
structureType?: string; structureType?: string;
@ApiProperty() @ApiProperty()
@IsBoolean() @IsString()
@IsOptional() @IsOptional()
@Transform(({ value }) => value === 'true' || value === true) // Convierte "false" -> false hasTransport?: string;
hasTransport?: boolean;
@ApiProperty() @ApiProperty()
@IsBoolean() @IsString()
@IsOptional() @IsOptional()
@Transform(({ value }) => value === 'true' || value === true) isOpenSpace?: string;
isOpenSpace?: boolean;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -124,6 +119,7 @@ export class CreateTrainingDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@IsOptional()
ospResponsibleRif: string; ospResponsibleRif: string;
@ApiProperty() @ApiProperty()
@@ -131,20 +127,25 @@ export class CreateTrainingDto {
ospResponsiblePhone: string; ospResponsiblePhone: string;
@ApiProperty() @ApiProperty()
@IsString() @IsOptional()
ospResponsibleEmail: string; @ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
ospResponsibleEmail?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@IsOptional()
civilState: string; civilState: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@IsOptional()
@Type(() => Number) // Convierte "3" -> 3 @Type(() => Number) // Convierte "3" -> 3
familyBurden: number; familyBurden: number;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@IsOptional()
@Type(() => Number) @Type(() => Number)
numberOfChildren: number; numberOfChildren: number;
@@ -165,21 +166,15 @@ export class CreateTrainingDto {
@IsString() @IsString()
communeSpokespersonName: string; communeSpokespersonName: string;
@ApiProperty()
@IsString()
communeSpokespersonCedula: string;
@ApiProperty()
@IsString()
communeSpokespersonRif: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
communeSpokespersonPhone: string; communeSpokespersonPhone: string;
@ApiProperty() @ApiProperty()
@IsString() @IsOptional()
communeEmail: string; @ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
communeEmail?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -197,25 +192,66 @@ export class CreateTrainingDto {
@IsString() @IsString()
communalCouncilSpokespersonName: string; communalCouncilSpokespersonName: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonCedula: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonRif: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
communalCouncilSpokespersonPhone: string; communalCouncilSpokespersonPhone: string;
@ApiProperty()
@IsOptional()
@ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
communalCouncilEmail?: string;
// === 6. DISTRIBUCIÓN Y EXPORTACIÓN ===
@ApiProperty() @ApiProperty()
@IsString() @IsString()
communalCouncilEmail: string; @IsOptional()
internalDistributionZone?: string;
@ApiProperty()
@IsString()
@IsOptional()
isExporting?: string;
@ApiProperty()
@IsString()
@IsOptional()
externalCountry?: string;
// === 6. LISTAS (Arrays JSON) === @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 // Reciben un string JSON '[{...}]' y lo convierten a Objeto JS real
@ApiProperty() @ApiProperty()
@@ -263,13 +299,11 @@ export class CreateTrainingDto {
}) })
productList?: any[]; productList?: any[];
//ubicacion //ubicacion
@ApiProperty() @ApiProperty()
@IsString() @IsString()
state: string; state: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
municipality: string; municipality: string;
@@ -277,4 +311,19 @@ export class CreateTrainingDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
parish: string; parish: string;
@ApiProperty()
@IsString()
@IsOptional()
photo1?: string;
@ApiProperty()
@IsString()
@IsOptional()
photo2?: string;
@ApiProperty()
@IsString()
@IsOptional()
photo3?: string;
} }

View File

@@ -7,11 +7,9 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
Res, Req,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
StreamableFile,
Header
} from '@nestjs/common'; } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express';
import { import {
@@ -26,30 +24,29 @@ import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto'; import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingService } from './training.service'; import { TrainingService } from './training.service';
import { Public } from '@/common/decorators';
@ApiTags('training') @ApiTags('training')
@Controller('training') @Controller('training')
export class TrainingController { export class TrainingController {
constructor(private readonly trainingService: TrainingService) {} constructor(private readonly trainingService: TrainingService) {}
@Public() // @Public()
@Get('export/:id') // @Get('export/:id')
@ApiOperation({ summary: 'Export training template' }) // @ApiOperation({ summary: 'Export training template' })
@ApiResponse({ // @ApiResponse({
status: 200, // status: 200,
description: 'Return training template.', // description: 'Return training template.',
content: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { schema: { type: 'string', format: 'binary' } } } // content: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { schema: { type: 'string', format: 'binary' } } }
}) // })
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') // @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename=export_osp.xlsx') // @Header('Content-Disposition', 'attachment; filename=export_osp.xlsx')
async exportTemplate(@Param('id') id: string) { // async exportTemplate(@Param('id') id: string) {
if (!Number(id)) { // if (!Number(id)) {
throw new Error('ID is required'); // throw new Error('ID is required');
} // }
const data = await this.trainingService.exportTemplate(Number(id)); // const data = await this.trainingService.exportTemplate(Number(id));
return new StreamableFile(data); // return new StreamableFile(data);
} // }
@Get() @Get()
@ApiOperation({ @ApiOperation({
@@ -94,10 +91,16 @@ export class TrainingController {
description: 'Training record created successfully.', description: 'Training record created successfully.',
}) })
async create( async create(
@Req() req: Request,
@Body() createTrainingDto: CreateTrainingDto, @Body() createTrainingDto: CreateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[], @UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) { ) {
const data = await this.trainingService.create(createTrainingDto, files); const userId = (req as any).user?.id;
const data = await this.trainingService.create(
createTrainingDto,
files,
userId,
);
return { message: 'Training record created successfully', data }; return { message: 'Training record created successfully', data };
} }
@@ -111,14 +114,17 @@ export class TrainingController {
}) })
@ApiResponse({ status: 404, description: 'Training record not found.' }) @ApiResponse({ status: 404, description: 'Training record not found.' })
async update( async update(
@Req() req: Request,
@Param('id') id: string, @Param('id') id: string,
@Body() updateTrainingDto: UpdateTrainingDto, @Body() updateTrainingDto: UpdateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[], @UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) { ) {
const userId = (req as any).user?.id;
const data = await this.trainingService.update( const data = await this.trainingService.update(
+id, +id,
updateTrainingDto, updateTrainingDto,
files, files,
userId,
); );
return { message: 'Training record updated successfully', data }; return { message: 'Training record updated successfully', data };
} }

View File

@@ -1,12 +1,11 @@
import { HttpException, HttpStatus, Inject, Injectable, NotFoundException } 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 { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as fs from 'fs';
import * as path from 'path';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import * as schema from 'src/database/index'; import * as schema from 'src/database/index';
import { municipalities, parishes, states, trainingSurveys } from 'src/database/index'; import { states, trainingSurveys } from 'src/database/index';
import XlsxPopulate from 'xlsx-populate';
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto'; import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
@@ -16,6 +15,7 @@ import { UpdateTrainingDto } from './dto/update-training.dto';
export class TrainingService { export class TrainingService {
constructor( constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>, @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
private readonly minioService: MinioService,
) {} ) {}
async findAll(paginationDto?: PaginationDto) { async findAll(paginationDto?: PaginationDto) {
@@ -107,17 +107,7 @@ export class TrainingService {
// 2. Total Productores (Columna plana que mantuviste) // 2. Total Productores (Columna plana que mantuviste)
this.drizzle this.drizzle
.select({ .select({
sum: sql<number>` sum: sql<number>`SUM(${trainingSurveys.womenCount} + ${trainingSurveys.menCount})`,
SUM(
(
SELECT SUM(
COALESCE((item->>'menCount')::int, 0) +
COALESCE((item->>'womenCount')::int, 0)
)
FROM jsonb_array_elements(${trainingSurveys.productList}) as item
)
)
`,
}) })
.from(trainingSurveys) .from(trainingSurveys)
.where(whereCondition), .where(whereCondition),
@@ -232,46 +222,54 @@ export class TrainingService {
private async saveFiles(files: Express.Multer.File[]): Promise<string[]> { private async saveFiles(files: Express.Multer.File[]): Promise<string[]> {
if (!files || files.length === 0) return []; if (!files || files.length === 0) return [];
const uploadDir = './uploads/training';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const savedPaths: string[] = []; const savedPaths: string[] = [];
for (const file of files) { for (const file of files) {
const fileName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`; const objectName = await this.minioService.upload(file, 'training');
const filePath = path.join(uploadDir, fileName); const fileUrl = this.minioService.getPublicUrl(objectName);
fs.writeFileSync(filePath, file.buffer); savedPaths.push(fileUrl);
savedPaths.push(`/assets/training/${fileName}`);
} }
return savedPaths; return savedPaths;
} }
private deleteFile(assetPath: string) { private async deleteFile(fileUrl: string) {
if (!assetPath) return; if (!fileUrl) return;
// Map /assets/training/filename.webp back to ./uploads/training/filename.webp
const relativePath = assetPath.replace('/assets/training/', '');
const fullPath = path.join('./uploads/training', relativePath);
if (fs.existsSync(fullPath)) {
try { try {
fs.unlinkSync(fullPath); // If it's a full URL, we need to extract the part after the bucket name
} catch (err) { if (fileUrl.startsWith('http')) {
console.error(`Error deleting file ${fullPath}:`, err); const url = new URL(fileUrl);
const pathname = url.pathname; // /bucket/folder/filename
const parts = pathname.split('/').filter(Boolean); // ['bucket', 'folder', 'filename']
// The first part is the bucket name, the rest is the object name
if (parts.length >= 2) {
const objectName = parts.slice(1).join('/');
await this.minioService.delete(objectName);
return;
} }
} }
// If it's not a URL or doesn't match the expected format, pass it as is
await this.minioService.delete(fileUrl);
} catch (error) {
// Fallback if URL parsing fails
await this.minioService.delete(fileUrl);
}
} }
async create( async create(
createTrainingDto: CreateTrainingDto, createTrainingDto: CreateTrainingDto,
files: Express.Multer.File[], files: Express.Multer.File[],
userId: number,
) { ) {
// 1. Guardar fotos // 1. Guardar fotos
const photoPaths = await this.saveFiles(files); const photoPaths = await this.saveFiles(files);
// 2. Extraer solo visitDate para formatearlo. // 2. Extraer solo visitDate para formatearlo.
// Ya NO extraemos state, municipality, etc. porque no vienen en el DTO. // Ya NO extraemos state, municipality, etc. porque no vienen en el DTO.
const { visitDate, state, municipality, parish, ...rest } = createTrainingDto; const { visitDate, state, municipality, parish, ...rest } =
createTrainingDto;
const [newRecord] = await this.drizzle const [newRecord] = await this.drizzle
.insert(trainingSurveys) .insert(trainingSurveys)
@@ -289,6 +287,11 @@ export class TrainingService {
state: Number(state) ?? null, state: Number(state) ?? null,
municipality: Number(municipality) ?? null, municipality: Number(municipality) ?? null,
parish: Number(parish) ?? 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(); .returning();
@@ -299,41 +302,68 @@ export class TrainingService {
id: number, id: number,
updateTrainingDto: UpdateTrainingDto, updateTrainingDto: UpdateTrainingDto,
files: Express.Multer.File[], files: Express.Multer.File[],
userId: number,
) { ) {
const currentRecord = await this.findOne(id); const currentRecord = await this.findOne(id);
const photoFields = ['photo1', 'photo2', 'photo3'] as const;
const photoPaths = await this.saveFiles(files); // 1. Guardar fotos nuevas en MinIO
const newFilePaths = await this.saveFiles(files);
const updateData: any = { ...updateTrainingDto }; const updateData: any = { ...updateTrainingDto };
// Handle photo updates/removals // 2. Determinar el estado final de las fotos (diff)
const photoFields = ['photo1', 'photo2', 'photo3'] as const; // - Si el DTO tiene un valor (URL existente o ''), lo usamos.
// - Si el DTO no tiene el campo (undefined), mantenemos el de la DB.
// 1. If we have NEW files, they replace any old files or occupy empty slots const finalPhotos: (string | null)[] = photoFields.map((field) => {
if (photoPaths.length > 0) { const dtoValue = updateData[field];
photoPaths.forEach((newPath, idx) => { if (dtoValue !== undefined) {
const fieldName = photoFields[idx]; return dtoValue === '' ? null : dtoValue;
const oldPath = currentRecord[fieldName];
if (oldPath && oldPath !== newPath) {
this.deleteFile(oldPath);
} }
updateData[fieldName] = newPath; 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++;
}
}
} }
// 2. If the user explicitly cleared a photo field (updateData.photoX === '') // 4. LIMPIEZA: Borrar de MinIO los archivos que ya no están en ningún slot
photoFields.forEach((field) => { const oldPhotos = photoFields
if (updateData[field] === '') { .map((f) => currentRecord[f])
const oldPath = currentRecord[field]; .filter((p): p is string => Boolean(p));
if (oldPath) this.deleteFile(oldPath); const newPhotosSet = new Set(finalPhotos.filter(Boolean));
updateData[field] = null; // Set to null in DB
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) { if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(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 const [updatedRecord] = await this.drizzle
.update(trainingSurveys) .update(trainingSurveys)
.set(updateData) .set(updateData)
@@ -347,9 +377,9 @@ export class TrainingService {
const record = await this.findOne(id); const record = await this.findOne(id);
// Delete associated files // Delete associated files
if (record.photo1) this.deleteFile(record.photo1); if (record.photo1) await this.deleteFile(record.photo1);
if (record.photo2) this.deleteFile(record.photo2); if (record.photo2) await this.deleteFile(record.photo2);
if (record.photo3) this.deleteFile(record.photo3); if (record.photo3) await this.deleteFile(record.photo3);
const [deletedRecord] = await this.drizzle const [deletedRecord] = await this.drizzle
.delete(trainingSurveys) .delete(trainingSurveys)
@@ -376,8 +406,7 @@ export class TrainingService {
// const records = await this.drizzle // const records = await this.drizzle
// .select({ // .select({
// firstname: trainingSurveys.firstname, // coorFullName: trainingSurveys.coorFullName,
// lastname: trainingSurveys.lastname,
// visitDate: trainingSurveys.visitDate, // visitDate: trainingSurveys.visitDate,
// stateName: states.name, // stateName: states.name,
// municipalityName: municipalities.name, // municipalityName: municipalities.name,
@@ -425,8 +454,7 @@ export class TrainingService {
// const dateStr = date.toLocaleDateString('es-VE'); // const dateStr = date.toLocaleDateString('es-VE');
// const timeStr = date.toLocaleTimeString('es-VE'); // const timeStr = date.toLocaleTimeString('es-VE');
// sheet.cell(`A${currentRow}`).value(record.firstname); // sheet.cell(`A${currentRow}`).value(record.coorFullName);
// sheet.cell(`B${currentRow}`).value(record.lastname);
// sheet.cell(`C${currentRow}`).value(dateStr); // sheet.cell(`C${currentRow}`).value(dateStr);
// sheet.cell(`D${currentRow}`).value(timeStr); // sheet.cell(`D${currentRow}`).value(timeStr);
// sheet.cell(`E${currentRow}`).value(record.stateName || ''); // sheet.cell(`E${currentRow}`).value(record.stateName || '');
@@ -478,139 +506,182 @@ export class TrainingService {
// return await workbook.outputAsync(); // return await workbook.outputAsync();
// } // }
async exportTemplate(id: number) { // 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`);
// Validar que el registro exista // // Obtener los datos del registro
const exist = await this.findOne(id); // const records = await this.drizzle
if (!exist) throw new NotFoundException(`No se encontro el registro`); // .select({
// // id: trainingSurveys.id,
// visitDate: trainingSurveys.visitDate,
// ospName: trainingSurveys.ospName,
// productiveSector: trainingSurveys.productiveSector,
// ospAddress: trainingSurveys.ospAddress,
// ospRif: trainingSurveys.ospRif,
// Obtener los datos del registro // siturCodeCommune: trainingSurveys.siturCodeCommune,
const records = await this.drizzle // communeEmail: trainingSurveys.communeEmail,
.select({ // communeRif: trainingSurveys.communeRif,
// id: trainingSurveys.id, // communeSpokespersonName: trainingSurveys.communeSpokespersonName,
visitDate: trainingSurveys.visitDate, // communeSpokespersonPhone: trainingSurveys.communeSpokespersonPhone,
ospName: trainingSurveys.ospName,
productiveSector: trainingSurveys.productiveSector,
ospAddress: trainingSurveys.ospAddress,
ospRif: trainingSurveys.ospRif,
siturCodeCommune: trainingSurveys.siturCodeCommune, // siturCodeCommunalCouncil: trainingSurveys.siturCodeCommunalCouncil,
communeEmail: trainingSurveys.communeEmail, // communalCouncilRif: trainingSurveys.communalCouncilRif,
communeRif: trainingSurveys.communeRif, // communalCouncilSpokespersonName:
communeSpokespersonName: trainingSurveys.communeSpokespersonName, // trainingSurveys.communalCouncilSpokespersonName,
communeSpokespersonPhone: trainingSurveys.communeSpokespersonPhone, // communalCouncilSpokespersonPhone:
// trainingSurveys.communalCouncilSpokespersonPhone,
siturCodeCommunalCouncil: trainingSurveys.siturCodeCommunalCouncil, // ospType: trainingSurveys.ospType,
communalCouncilRif: trainingSurveys.communalCouncilRif, // productiveActivity: trainingSurveys.productiveActivity, // Sector Productivo
communalCouncilSpokespersonName: trainingSurveys.communalCouncilSpokespersonName, // companyConstitutionYear: trainingSurveys.companyConstitutionYear,
communalCouncilSpokespersonPhone: trainingSurveys.communalCouncilSpokespersonPhone, // infrastructureMt2: trainingSurveys.infrastructureMt2,
ospType: trainingSurveys.ospType, // hasTransport: trainingSurveys.hasTransport,
productiveActivity: trainingSurveys.productiveActivity, // Sector Productivo // structureType: trainingSurveys.structureType,
companyConstitutionYear: trainingSurveys.companyConstitutionYear, // isOpenSpace: trainingSurveys.isOpenSpace,
infrastructureMt2: trainingSurveys.infrastructureMt2,
hasTransport: trainingSurveys.hasTransport, // ospResponsibleFullname: trainingSurveys.ospResponsibleFullname,
structureType: trainingSurveys.structureType, // ospResponsibleCedula: trainingSurveys.ospResponsibleCedula,
isOpenSpace: trainingSurveys.isOpenSpace, // ospResponsiblePhone: trainingSurveys.ospResponsiblePhone,
ospResponsibleFullname: trainingSurveys.ospResponsibleFullname, // productList: trainingSurveys.productList,
ospResponsibleCedula: trainingSurveys.ospResponsibleCedula, // equipmentList: trainingSurveys.equipmentList,
ospResponsiblePhone: trainingSurveys.ospResponsiblePhone, // productionList: trainingSurveys.productionList,
productList: trainingSurveys.productList, // // photo1: trainingSurveys.photo1
equipmentList: trainingSurveys.equipmentList, // })
productionList: trainingSurveys.productionList, // .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))
// photo1: trainingSurveys.photo1 // let equipmentList: any[] = Array.isArray(records[0].equipmentList)
}) // ? records[0].equipmentList
.from(trainingSurveys) // : [];
.where(eq(trainingSurveys.id, id)) // let productList: any[] = Array.isArray(records[0].productList)
// .leftJoin(states, eq(trainingSurveys.state, states.id)) // ? records[0].productList
// .leftJoin(municipalities,eq(trainingSurveys.municipality, municipalities.id)) // : [];
// .leftJoin(parishes, eq(trainingSurveys.parish, parishes.id)) // let productionList: any[] = Array.isArray(records[0].productionList)
// ? records[0].productionList
// : [];
let equipmentList: any[] = Array.isArray(records[0].equipmentList) ? records[0].equipmentList : []; // console.log('equipmentList', equipmentList);
let productList: any[] = Array.isArray(records[0].productList) ? records[0].productList : []; // console.log('productList', productList);
let productionList: any[] = Array.isArray(records[0].productionList) ? records[0].productionList : []; // console.log('productionList', productionList);
console.log('equipmentList', equipmentList); // let equipmentListArray: any[] = [];
console.log('productList', productList); // let productListArray: any[] = [];
console.log('productionList', productionList); // let productionListArray: any[] = [];
let equipmentListArray: any[] = []; // const equipmentListCount = equipmentList.length;
let productListArray: any[] = []; // for (let i = 0; i < equipmentListCount; i++) {
let productionListArray: any[] = []; // equipmentListArray.push([
// equipmentList[i].machine,
// '',
// equipmentList[i].quantity,
// ]);
// }
const equipmentListCount = equipmentList.length; // const productListCount = productList.length;
for (let i = 0; i < equipmentListCount; i++) { // for (let i = 0; i < productListCount; i++) {
equipmentListArray.push([equipmentList[i].machine, '', equipmentList[i].quantity]); // productListArray.push([
} // productList[i].productName,
// productList[i].dailyCount,
const productListCount = productList.length; // productList[i].weeklyCount,
for (let i = 0; i < productListCount; i++) { // productList[i].monthlyCount,
productListArray.push([productList[i].productName, productList[i].dailyCount, productList[i].weeklyCount, productList[i].monthlyCount]); // ]);
} // }
const productionListCount = productionList.length; // const productionListCount = productionList.length;
for (let i = 0; i < productionListCount; i++) { // for (let i = 0; i < productionListCount; i++) {
productionListArray.push([productionList[i].rawMaterial, '', productionList[i].quantity]); // productionListArray.push([
} // productionList[i].rawMaterial,
// '',
// Ruta de la plantilla // productionList[i].quantity,
const templatePath = path.join( // ]);
__dirname, // }
'export_template',
'excel.osp.xlsx', // // Ruta de la plantilla
); // const templatePath = path.join(
// __dirname,
// Cargar la plantilla // 'export_template',
const book = await XlsxPopulate.fromFileAsync(templatePath); // 'excel.osp.xlsx',
// );
const isoString = records[0].visitDate;
const dateObj = new Date(isoString); // // Cargar la plantilla
const fechaFormateada = dateObj.toLocaleDateString('es-ES'); // const book = await XlsxPopulate.fromFileAsync(templatePath);
const horaFormateada = dateObj.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
// const isoString = records[0].visitDate;
// Llenar los datos // const dateObj = new Date(isoString);
book.sheet(0).cell('A6').value(records[0].productiveSector); // const fechaFormateada = dateObj.toLocaleDateString('es-ES');
book.sheet(0).cell('D6').value(records[0].ospName); // const horaFormateada = dateObj.toLocaleTimeString('es-ES', {
book.sheet(0).cell('L5').value(fechaFormateada); // hour: '2-digit',
book.sheet(0).cell('L6').value(horaFormateada); // minute: '2-digit',
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); // // Llenar los datos
book.sheet(0).cell('G11').value(records[0].communeRif); // book.sheet(0).cell('A6').value(records[0].productiveSector);
book.sheet(0).cell('G12').value(records[0].communeSpokespersonPhone); // book.sheet(0).cell('D6').value(records[0].ospName);
book.sheet(0).cell('C13').value(records[0].siturCodeCommune); // book.sheet(0).cell('L5').value(fechaFormateada);
book.sheet(0).cell('G13').value(records[0].siturCodeCommunalCouncil); // book.sheet(0).cell('L6').value(horaFormateada);
book.sheet(0).cell('G14').value(records[0].communalCouncilRif); // book.sheet(0).cell('B10').value(records[0].ospAddress);
book.sheet(0).cell('C15').value(records[0].communalCouncilSpokespersonName); // book.sheet(0).cell('C11').value(records[0].communeEmail);
book.sheet(0).cell('G15').value(records[0].communalCouncilSpokespersonPhone); // book.sheet(0).cell('C12').value(records[0].communeSpokespersonName);
book.sheet(0).cell('C16').value(records[0].ospType); // book.sheet(0).cell('G11').value(records[0].communeRif);
book.sheet(0).cell('C17').value(records[0].ospName); // book.sheet(0).cell('G12').value(records[0].communeSpokespersonPhone);
book.sheet(0).cell('C18').value(records[0].productiveActivity); // book.sheet(0).cell('C13').value(records[0].siturCodeCommune);
book.sheet(0).cell('C19').value('Proveedores'); // book.sheet(0).cell('G13').value(records[0].siturCodeCommunalCouncil);
book.sheet(0).cell('C20').value(records[0].companyConstitutionYear); // book.sheet(0).cell('G14').value(records[0].communalCouncilRif);
book.sheet(0).cell('C21').value(records[0].infrastructureMt2); // book.sheet(0).cell('C15').value(records[0].communalCouncilSpokespersonName);
book.sheet(0).cell('G17').value(records[0].ospRif); // book
// .sheet(0)
book.sheet(0).cell(records[0].hasTransport === true ? 'J19' : 'L19').value('X'); // .cell('G15')
book.sheet(0).cell(records[0].structureType === 'CASA' ? 'J20' : 'L20').value('X'); // .value(records[0].communalCouncilSpokespersonPhone);
book.sheet(0).cell(records[0].isOpenSpace === true ? 'J21' : 'L21').value('X'); // book.sheet(0).cell('C16').value(records[0].ospType);
// book.sheet(0).cell('C17').value(records[0].ospName);
book.sheet(0).cell('A24').value(records[0].ospResponsibleFullname); // book.sheet(0).cell('C18').value(records[0].productiveActivity);
book.sheet(0).cell('C24').value(records[0].ospResponsibleCedula); // book.sheet(0).cell('C19').value('Proveedores');
book.sheet(0).cell('E24').value(records[0].ospResponsiblePhone); // 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('J24').value('N Femenino');
book.sheet(0).cell('L24').value('N Masculino'); // book
// .sheet(0)
book.sheet(0).range(`A28:C${equipmentListCount + 28}`).value(equipmentListArray); // .cell(records[0].hasTransport === true ? 'J19' : 'L19')
book.sheet(0).range(`E28:G${productionListCount + 28}`).value(productionListArray); // .value('X');
book.sheet(0).range(`I28:L${productListCount + 28}`).value(productListArray); // book
// .sheet(0)
return book.outputAsync(); // .cell(records[0].structureType === 'CASA' ? 'J20' : 'L20')
} // .value('X');
// book
// .sheet(0)
// .cell(records[0].isOpenSpace === true ? 'J21' : 'L21')
// .value('X');
// book.sheet(0).cell('A24').value(records[0].ospResponsibleFullname);
// book.sheet(0).cell('C24').value(records[0].ospResponsibleCedula);
// book.sheet(0).cell('E24').value(records[0].ospResponsiblePhone);
// book.sheet(0).cell('J24').value('N Femenino');
// book.sheet(0).cell('L24').value('N Masculino');
// book
// .sheet(0)
// .range(`A28:C${equipmentListCount + 28}`)
// .value(equipmentListArray);
// book
// .sheet(0)
// .range(`E28:G${productionListCount + 28}`)
// .value(productionListArray);
// book
// .sheet(0)
// .range(`I28:L${productListCount + 28}`)
// .value(productListArray);
// return book.outputAsync();
// }
} }

View File

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

View File

@@ -21,14 +21,12 @@ export default function EditTrainingPage() {
} }
return ( return (
<PageContainer scrollable> <div className="p-6 space-y-6">
<div className="flex-1 space-y-4">
<CreateTrainingForm <CreateTrainingForm
defaultValues={training} defaultValues={training}
onSuccess={() => router.push('/dashboard/formulario')} onSuccess={() => router.push('/dashboard/formulario')}
onCancel={() => router.back()} onCancel={() => router.back()}
/> />
</div> </div>
</PageContainer>
); );
} }

View File

@@ -1,4 +1,4 @@
import PageContainer from '@/components/layout/page-container'; // import PageContainer from '@/components/layout/page-container';
import { TrainingHeader } from '@/feactures/training/components/training-header'; import { TrainingHeader } from '@/feactures/training/components/training-header';
import TrainingList from '@/feactures/training/components/training-list'; import TrainingList from '@/feactures/training/components/training-list';
import TrainingTableAction from '@/feactures/training/components/training-tables/training-table-action'; import TrainingTableAction from '@/feactures/training/components/training-tables/training-table-action';
@@ -23,8 +23,8 @@ export default async function Page({ searchParams }: PageProps) {
} = searchParamsCache.parse(await searchParams); } = searchParamsCache.parse(await searchParams);
return ( return (
<PageContainer> // <PageContainer>
<div className="flex flex-1 flex-col space-y-6"> <div className="flex flex-1 flex-col space-y-6 p-6">
<TrainingHeader /> <TrainingHeader />
<TrainingTableAction /> <TrainingTableAction />
<TrainingList <TrainingList
@@ -34,6 +34,6 @@ export default async function Page({ searchParams }: PageProps) {
apiUrl={env.API_URL} apiUrl={env.API_URL}
/> />
</div> </div>
</PageContainer> // </PageContainer>
); );
} }

View File

@@ -187,10 +187,9 @@ export const COUNTRY_OPTIONS = [
'Uruguay', 'Uruguay',
'Uzbekistán', 'Uzbekistán',
'Vanuatu', 'Vanuatu',
'Venezuela',
'Vietnam', 'Vietnam',
'Yemen', 'Yemen',
'Yibuti', 'Yibuti',
'Zambia', 'Zambia',
'Zimbabue' 'Zimbabue',
]; ];

View File

@@ -34,7 +34,7 @@ export const AdministrationItems: NavItem[] = [
url: '/dashboard/administracion/usuario', url: '/dashboard/administracion/usuario',
icon: 'userPen', icon: 'userPen',
shortcut: ['m', 'm'], shortcut: ['m', 'm'],
role: ['admin', 'superadmin', 'autoridad'], role: ['admin', 'superadmin'],
}, },
{ {
title: 'Encuestas', title: 'Encuestas',
@@ -60,7 +60,7 @@ export const StatisticsItems: NavItem[] = [
url: '#', // Placeholder as there is no direct link for the parent url: '#', // Placeholder as there is no direct link for the parent
icon: 'chartColumn', icon: 'chartColumn',
isActive: true, isActive: true,
role: ['admin', 'superadmin', 'autoridad', 'manager'], role: ['admin', 'superadmin', 'autoridad'],
items: [ items: [
// { // {
@@ -82,7 +82,7 @@ export const StatisticsItems: NavItem[] = [
shortcut: ['s', 's'], shortcut: ['s', 's'],
url: '/dashboard/estadisticas/socioproductiva', url: '/dashboard/estadisticas/socioproductiva',
icon: 'blocks', icon: 'blocks',
role: ['admin', 'superadmin', 'autoridad', 'manager'], role: ['admin', 'superadmin', 'autoridad'],
}, },
], ],
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,8 +90,6 @@ export const createTrainingAction = async (
payloadToSend = rest as any; payloadToSend = rest as any;
} }
console.log(payloadToSend);
const [error, data] = await safeFetchApi( const [error, data] = await safeFetchApi(
TrainingMutate, TrainingMutate,
'/training', '/training',

View File

@@ -20,24 +20,34 @@ import { Label } from '@repo/shadcn/label';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form'; import { useFieldArray, useFormContext } from 'react-hook-form';
import { TrainingSchema } from '../schemas/training';
interface EquipmentItem {
machine: string;
quantity: string | number;
}
export function EquipmentList() { export function EquipmentList() {
const { control, register } = useFormContext(); const { control, register } = useFormContext<TrainingSchema>();
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
name: 'equipmentList', name: 'equipmentList',
}); });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [newItem, setNewItem] = useState({ const [newItem, setNewItem] = useState<EquipmentItem>({
machine: '', machine: '',
specifications: '',
quantity: '', quantity: '',
}); });
const handleAdd = () => { const handleAdd = (e: React.MouseEvent) => {
if (newItem.machine && newItem.quantity) { e.preventDefault();
append({ ...newItem, quantity: Number(newItem.quantity) }); e.stopPropagation();
setNewItem({ machine: '', specifications: '', quantity: '' }); if (newItem.machine.trim()) {
append({
machine: newItem.machine,
quantity: newItem.quantity ? Number(newItem.quantity) : 0,
});
setNewItem({ machine: '', quantity: '' });
setIsOpen(false); setIsOpen(false);
} }
}; };
@@ -48,9 +58,11 @@ export function EquipmentList() {
<h3 className="text-lg font-medium">Datos del Equipamiento</h3> <h3 className="text-lg font-medium">Datos del Equipamiento</h3>
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">Agregar Maquinaria</Button> <Button variant="outline" type="button">
Agregar Maquinaria
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Agregar Maquinaria/Equipo</DialogTitle> <DialogTitle>Agregar Maquinaria/Equipo</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -59,8 +71,9 @@ export function EquipmentList() {
</DialogDescription> </DialogDescription>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Maquinaria</Label> <Label htmlFor="modal-machine">Maquinaria</Label>
<Input <Input
id="modal-machine"
value={newItem.machine} value={newItem.machine}
onChange={(e) => onChange={(e) =>
setNewItem({ ...newItem, machine: e.target.value }) setNewItem({ ...newItem, machine: e.target.value })
@@ -69,18 +82,9 @@ export function EquipmentList() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Especificaciones</Label> <Label htmlFor="modal-quantity">Cantidad</Label>
<Input
value={newItem.specifications}
onChange={(e) =>
setNewItem({ ...newItem, specifications: e.target.value })
}
placeholder="Especificaciones técnicas"
/>
</div>
<div className="space-y-2">
<Label>Cantidad</Label>
<Input <Input
id="modal-quantity"
type="number" type="number"
value={newItem.quantity} value={newItem.quantity}
onChange={(e) => onChange={(e) =>
@@ -93,12 +97,17 @@ export function EquipmentList() {
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => setIsOpen(false)} onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleAdd}>Guardar</Button> <Button type="button" onClick={handleAdd}>
Guardar
</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -110,7 +119,6 @@ export function EquipmentList() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Maquinaria</TableHead> <TableHead>Maquinaria</TableHead>
<TableHead>Especificaciones</TableHead>
<TableHead>Cantidad</TableHead> <TableHead>Cantidad</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
@@ -122,31 +130,27 @@ export function EquipmentList() {
<input <input
type="hidden" type="hidden"
{...register(`equipmentList.${index}.machine`)} {...register(`equipmentList.${index}.machine`)}
defaultValue={field.machine ?? ''}
/> />
{/* @ts-ignore */}
{field.machine} {field.machine}
</TableCell> </TableCell>
<TableCell>
<input
type="hidden"
{...register(`equipmentList.${index}.specifications`)}
/>
{/* @ts-ignore */}
{field.specifications}
</TableCell>
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`equipmentList.${index}.quantity`)} {...register(`equipmentList.${index}.quantity`)}
defaultValue={field.quantity ?? ''}
/> />
{/* @ts-ignore */}
{field.quantity} {field.quantity}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => remove(index)} type="button"
onClick={(e) => {
e.preventDefault();
remove(index);
}}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
@@ -156,7 +160,7 @@ export function EquipmentList() {
{fields.length === 0 && ( {fields.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={4} colSpan={3}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
No hay equipamiento registrado No hay equipamiento registrado

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,3 @@
import { COUNTRY_OPTIONS } from '@/constants/countries';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
import { Button } from '@repo/shadcn/button'; import { Button } from '@repo/shadcn/button';
import { import {
Dialog, Dialog,
@@ -23,36 +17,18 @@ import {
} from '@repo/shadcn/components/ui/table'; } from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input'; import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label'; import { Label } from '@repo/shadcn/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { SelectSearchable } from '@repo/shadcn/select-searchable';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form'; 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 // 1. Definimos la estructura de los datos para que TypeScript no se queje
interface ProductItem { // ProductItem y ProductFormValues locales eliminados en favor de TrainingSchema
productName: string;
description: string;
dailyCount: string;
weeklyCount: string;
monthlyCount: string;
// ... resto de propiedades opcionales si las necesitas tipar estrictamente
[key: string]: any;
}
interface ProductFormValues {
productList: ProductItem[];
}
export function ProductActivityList() { export function ProductActivityList() {
// 2. Pasamos el tipo genérico a useFormContext const { control, register } = useFormContext<TrainingSchema>();
const { control, register } = useFormContext<ProductFormValues>();
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
@@ -63,91 +39,25 @@ export function ProductActivityList() {
// Modal Form State // Modal Form State
const [newItem, setNewItem] = useState<any>({ const [newItem, setNewItem] = useState<any>({
productName: '',
description: '', description: '',
dailyCount: '', dailyCount: '',
weeklyCount: '', weeklyCount: '',
monthlyCount: '', monthlyCount: '',
// Internal dist
internalState: undefined,
internalMunicipality: undefined,
internalParish: undefined,
internalDescription: '',
internalQuantity: '',
internalUnit: '',
// External dist
externalCountry: '',
externalState: undefined,
externalMunicipality: undefined,
externalParish: undefined,
externalCity: '',
externalDescription: '',
externalQuantity: '',
externalUnit: '',
// Workforce
womenCount: '',
menCount: '',
}); });
// Location logic for Internal Validation
const [internalStateId, setInternalStateId] = useState(0);
const [internalMuniId, setInternalMuniId] = useState(0);
const { data: statesData } = useStateQuery();
const { data: internalMuniData } = useMunicipalityQuery(internalStateId);
const { data: internalParishData } = useParishQuery(internalMuniId);
// Location logic for External Validation
const [externalStateId, setExternalStateId] = useState(0);
const [externalMuniId, setExternalMuniId] = useState(0);
const { data: externalMuniData } = useMunicipalityQuery(externalStateId);
const { data: externalParishData } = useParishQuery(externalMuniId);
const isVenezuela = newItem.externalCountry === 'Venezuela';
const handleAdd = () => { const handleAdd = () => {
if (newItem.productName) { if (newItem.description) {
append(newItem); append(newItem);
setNewItem({ setNewItem({
productName: '',
description: '', description: '',
dailyCount: '', dailyCount: '',
weeklyCount: '', weeklyCount: '',
monthlyCount: '', monthlyCount: '',
internalState: undefined,
internalMunicipality: undefined,
internalParish: undefined,
internalDescription: '',
internalQuantity: '',
internalUnit: '',
externalCountry: '',
externalState: undefined,
externalMunicipality: undefined,
externalParish: undefined,
externalCity: '',
externalDescription: '',
externalQuantity: '',
externalUnit: '',
womenCount: '',
menCount: '',
}); });
setInternalStateId(0);
setInternalMuniId(0);
setExternalStateId(0);
setExternalMuniId(0);
setIsOpen(false); setIsOpen(false);
} }
}; };
const stateOptions = statesData?.data || [];
const internalMuniOptions = internalMuniData?.data || [];
const internalParishOptions = internalParishData?.data || [];
const externalMuniOptions = externalMuniData?.data || [];
const externalParishOptions = externalParishData?.data || [];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -158,7 +68,7 @@ export function ProductActivityList() {
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto"> <DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Detalles de Actividad Productiva</DialogTitle> <DialogTitle>Producto Terminado</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Datos de actividad productiva Datos de actividad productiva
@@ -166,15 +76,6 @@ export function ProductActivityList() {
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Basic Info */} {/* Basic Info */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Producto Terminado</Label>
<Input
value={newItem.productName}
onChange={(e) =>
setNewItem({ ...newItem, productName: e.target.value })
}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Descripción</Label> <Label>Descripción</Label>
<Input <Input
@@ -219,222 +120,6 @@ export function ProductActivityList() {
</div> </div>
</div> </div>
<hr />
<h4 className="font-semibold">Distribución Interna</h4>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Estado</Label>
<SelectSearchable
options={stateOptions.map((s) => ({
value: String(s.id),
label: s.name,
}))}
onValueChange={(val) => {
const id = Number(val);
setInternalStateId(id);
setNewItem({ ...newItem, internalState: id });
}}
placeholder="Estado"
/>
</div>
<div className="space-y-2">
<Label>Municipio</Label>
<SelectSearchable
options={internalMuniOptions.map((s) => ({
value: String(s.id),
label: s.name,
}))}
onValueChange={(val) => {
const id = Number(val);
setInternalMuniId(id);
setNewItem({ ...newItem, internalMunicipality: id });
}}
placeholder="Municipio"
disabled={!internalStateId}
/>
</div>
<div className="space-y-2">
<Label>Parroquia</Label>
<SelectSearchable
options={internalParishOptions.map((s) => ({
value: String(s.id),
label: s.name,
}))}
onValueChange={(val) =>
setNewItem({ ...newItem, internalParish: Number(val) })
}
placeholder="Parroquia"
disabled={!internalMuniId}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Breve Descripción</Label>
<Input
value={newItem.internalDescription}
onChange={(e) =>
setNewItem({
...newItem,
internalDescription: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Cantidad Numérica (Kg, TON, UNID. LT)</Label>
<Input
type="number"
value={newItem.internalQuantity}
onChange={(e) =>
setNewItem({
...newItem,
internalQuantity: e.target.value,
})
}
/>
</div>
</div>
<hr />
<h4 className="font-semibold">Distribución Externa</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>País</Label>
<Select
value={newItem.externalCountry}
onValueChange={(val) =>
setNewItem({ ...newItem, externalCountry: val })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleccione País" />
</SelectTrigger>
<SelectContent>
{/* 3. CORRECCIÓN DEL MAPEO DE PAÍSES Y KEYS */}
{COUNTRY_OPTIONS.map((country: string) => (
<SelectItem key={country} value={country}>
{country}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!isVenezuela && (
<div className="space-y-2">
<Label>Ciudad</Label>
<Input
value={newItem.externalCity}
onChange={(e) =>
setNewItem({ ...newItem, externalCity: e.target.value })
}
/>
</div>
)}
</div>
{isVenezuela && (
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Estado</Label>
<SelectSearchable
options={stateOptions.map((s) => ({
value: String(s.id),
label: s.name,
}))}
onValueChange={(val) => {
const id = Number(val);
setExternalStateId(id);
setNewItem({ ...newItem, externalState: id });
}}
placeholder="Estado"
/>
</div>
<div className="space-y-2">
<Label>Municipio</Label>
<SelectSearchable
options={externalMuniOptions.map((s) => ({
value: String(s.id),
label: s.name,
}))}
onValueChange={(val) => {
const id = Number(val);
setExternalMuniId(id);
setNewItem({ ...newItem, externalMunicipality: id });
}}
placeholder="Municipio"
disabled={!externalStateId}
/>
</div>
<div className="space-y-2">
<Label>Parroquia</Label>
<SelectSearchable
options={externalParishOptions.map((s) => ({
value: String(s.id),
label: s.name,
}))}
onValueChange={(val) =>
setNewItem({ ...newItem, externalParish: Number(val) })
}
placeholder="Parroquia"
disabled={!externalMuniId}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Breve Descripción</Label>
<Input
value={newItem.externalDescription}
onChange={(e) =>
setNewItem({
...newItem,
externalDescription: e.target.value,
})
}
/>
</div>
<div className="space-y-2">
<Label>Cantidad Numérica (Kg, TON, UNID. LT)</Label>
<Input
type="number"
value={newItem.externalQuantity}
onChange={(e) =>
setNewItem({
...newItem,
externalQuantity: e.target.value,
})
}
/>
</div>
</div>
<hr />
<h4 className="font-semibold">Mano de Obra</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Mujer (cantidad)</Label>
<Input
type="number"
value={newItem.womenCount}
onChange={(e) =>
setNewItem({ ...newItem, womenCount: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Hombre (cantidad)</Label>
<Input
type="number"
value={newItem.menCount}
onChange={(e) =>
setNewItem({ ...newItem, menCount: e.target.value })
}
/>
</div>
</div>
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<Button <Button
variant="outline" variant="outline"
@@ -455,9 +140,10 @@ export function ProductActivityList() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Producto</TableHead> <TableHead>Producto/Descripción</TableHead>
<TableHead>Descripción</TableHead> <TableHead>Producción Diario</TableHead>
<TableHead>Mensual</TableHead> <TableHead>Producción Semanal</TableHead>
<TableHead>Producción Mensual</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -467,13 +153,28 @@ export function ProductActivityList() {
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`productList.${index}.productName`)} {...register(`productList.${index}.description`)}
// field.productName ahora es válido gracias a la interface defaultValue={field.description ?? ''}
value={field.productName}
/> />
{field.productName} <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>
<TableCell>{field.description}</TableCell> <TableCell>{field.dailyCount}</TableCell>
<TableCell>{field.weeklyCount}</TableCell>
<TableCell>{field.monthlyCount}</TableCell> <TableCell>{field.monthlyCount}</TableCell>
<TableCell> <TableCell>
<Button <Button

View File

@@ -17,27 +17,39 @@ import {
} from '@repo/shadcn/components/ui/table'; } from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input'; import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label'; import { Label } from '@repo/shadcn/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form'; 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() { export function ProductionList() {
const { control, register } = useFormContext(); const { control, register } = useFormContext<TrainingSchema>();
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
name: 'productionList', name: 'productionList',
}); });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [newItem, setNewItem] = useState({ const [newItem, setNewItem] = useState({
rawMaterial: '',
supplyType: '', supplyType: '',
quantity: '', quantity: '',
unit: '',
}); });
const handleAdd = () => { const handleAdd = (e: React.MouseEvent) => {
if (newItem.rawMaterial && newItem.quantity) { e.preventDefault();
e.stopPropagation();
if (newItem.supplyType && newItem.quantity && newItem.unit) {
append({ ...newItem, quantity: Number(newItem.quantity) }); append({ ...newItem, quantity: Number(newItem.quantity) });
setNewItem({ rawMaterial: '', supplyType: '', quantity: '' }); setNewItem({ supplyType: '', quantity: '', unit: '' });
setIsOpen(false); setIsOpen(false);
} }
}; };
@@ -50,24 +62,14 @@ export function ProductionList() {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">Agregar Producción</Button> <Button variant="outline">Agregar Producción</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Agregar Datos de Producción</DialogTitle> <DialogTitle>Materia prima requerida (mensual)</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Datos de producción Datos de producción
</DialogDescription> </DialogDescription>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Materia prima requerida (mensual)</Label>
<Input
value={newItem.rawMaterial}
onChange={(e) =>
setNewItem({ ...newItem, rawMaterial: e.target.value })
}
placeholder="Descripción de materia prima"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Tipo de Insumo/Rubro</Label> <Label>Tipo de Insumo/Rubro</Label>
<Input <Input
@@ -79,26 +81,57 @@ export function ProductionList() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Cantidad Mensual (Kg, TON, UNID. LT)</Label> <Label>Cantidad Mensual</Label>
<div className="flex gap-2">
<Input <Input
type="number" type="number"
className="flex-1"
value={newItem.quantity} value={newItem.quantity}
onChange={(e) => onChange={(e) =>
setNewItem({ ...newItem, quantity: e.target.value }) setNewItem({ ...newItem, quantity: e.target.value })
} }
placeholder="0" 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>
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => setIsOpen(false)} onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleAdd}>Guardar</Button> <Button
type="button"
onClick={handleAdd}
disabled={
!newItem.supplyType || !newItem.quantity || !newItem.unit
}
>
Guardar
</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -109,7 +142,6 @@ export function ProductionList() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Materia Prima</TableHead>
<TableHead>Tipo Insumo</TableHead> <TableHead>Tipo Insumo</TableHead>
<TableHead>Cantidad (Mensual)</TableHead> <TableHead>Cantidad (Mensual)</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
@@ -118,35 +150,36 @@ export function ProductionList() {
<TableBody> <TableBody>
{fields.map((field, index) => ( {fields.map((field, index) => (
<TableRow key={field.id}> <TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.rawMaterial`)}
/>
{/* @ts-ignore */}
{field.rawMaterial}
</TableCell>
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`productionList.${index}.supplyType`)} {...register(`productionList.${index}.supplyType`)}
defaultValue={field.supplyType ?? ''}
/> />
{/* @ts-ignore */}
{field.supplyType} {field.supplyType}
</TableCell> </TableCell>
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`productionList.${index}.quantity`)} {...register(`productionList.${index}.quantity`)}
defaultValue={field.quantity ?? ''}
/> />
{/* @ts-ignore */} <input
{field.quantity} type="hidden"
{...register(`productionList.${index}.unit`)}
defaultValue={field.unit ?? ''}
/>
{field.quantity} {field.unit}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => remove(index)} type="button"
onClick={(e) => {
e.preventDefault();
remove(index);
}}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>

View File

@@ -29,10 +29,18 @@ export default function TrainingList({
return <DataTableSkeleton columnCount={5} rowCount={initialLimit} />; return <DataTableSkeleton columnCount={5} rowCount={initialLimit} />;
} }
const transformedData =
data?.data?.map((item) => ({
...item,
communeRif: item.communeRif || '',
communeSpokespersonName: item.communeSpokespersonName || '',
communalCouncilRif: item.communalCouncilRif || '',
})) || [];
return ( return (
<DataTable <DataTable
columns={columns({ apiUrl })} columns={columns({ apiUrl })}
data={data?.data || []} data={transformedData}
totalItems={data?.meta.totalCount || 0} totalItems={data?.meta.totalCount || 0}
pageSizeOptions={[10, 20, 30, 40, 50]} pageSizeOptions={[10, 20, 30, 40, 50]}
/> />

View File

@@ -9,7 +9,8 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@repo/shadcn/tooltip'; } from '@repo/shadcn/tooltip';
import { Edit, Eye, Trash, FileDown } from 'lucide-react'; import { Edit, Eye, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { TrainingViewModal } from '../training-view-modal'; import { TrainingViewModal } from '../training-view-modal';
@@ -25,6 +26,7 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
const [viewOpen, setViewOpen] = useState(false); const [viewOpen, setViewOpen] = useState(false);
const { mutate: deleteTraining } = useDeleteTraining(); const { mutate: deleteTraining } = useDeleteTraining();
const router = useRouter(); const router = useRouter();
const { data: session } = useSession();
const onConfirm = async () => { const onConfirm = async () => {
try { try {
@@ -38,9 +40,29 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
} }
}; };
const handleExport = (id?: number | undefined) => { // Mapear roles a minúsculas para comparación segura
window.open(`${apiUrl}/training/export/${id}`, '_blank'); 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 ( return (
<> <>
@@ -60,6 +82,8 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
/> />
<div className="flex gap-1"> <div className="flex gap-1">
{/* VER DETALLE: superadmin, admin, autoridad, manager, or owner coordinator */}
{(isAdminOrSuper || isOtherAuthorized || (isCoordinator && isOwner)) && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -76,24 +100,10 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
{/* <TooltipProvider> {/* EDITAR: Superadmin, admin OR (coordinator if owner) */}
<Tooltip> {(isAdminOrSuper || (isCoordinator && isOwner)) && (
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => handleExport(data.id)}
>
<FileDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Exportar Excel</p>
</TooltipContent>
</Tooltip>
</TooltipProvider> */}
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -112,7 +122,10 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
{/* ELIMINAR: Solo superadmin y admin */}
{isAdminOrSuper && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -129,6 +142,7 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)}
</div> </div>
</> </>
); );

View File

@@ -22,6 +22,15 @@ export function columns({ apiUrl }: ColumnsProps): ColumnDef<TrainingSchema>[] {
accessorKey: 'ospType', accessorKey: 'ospType',
header: 'Tipo', 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', accessorKey: 'currentStatus',
header: 'Estatus', header: 'Estatus',

View File

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

View File

@@ -1,5 +1,10 @@
'use client'; 'use client';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
import { Badge } from '@repo/shadcn/badge'; import { Badge } from '@repo/shadcn/badge';
import { Button } from '@repo/shadcn/button'; import { Button } from '@repo/shadcn/button';
import { import {
@@ -17,7 +22,6 @@ import {
DialogTitle, DialogTitle,
} from '@repo/shadcn/components/ui/dialog'; } from '@repo/shadcn/components/ui/dialog';
import { ScrollArea } from '@repo/shadcn/components/ui/scroll-area'; import { ScrollArea } from '@repo/shadcn/components/ui/scroll-area';
import { Separator } from '@repo/shadcn/components/ui/separator';
import { import {
ExternalLink, ExternalLink,
Factory, Factory,
@@ -28,11 +32,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { TrainingSchema } from '../schemas/training'; import { TrainingSchema } from '../schemas/training';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
interface TrainingViewModalProps { interface TrainingViewModalProps {
data: TrainingSchema | null; data: TrainingSchema | null;
@@ -53,7 +52,9 @@ export function TrainingViewModal({
if (!data) return null; if (!data) return null;
const stateName = statesData?.data?.find((s: any) => s.id === data.state)?.name; const stateName = statesData?.data?.find(
(s: any) => s.id === data.state,
)?.name;
const municipalityName = municipalitiesData?.data?.find( const municipalityName = municipalitiesData?.data?.find(
(m: any) => m.id === data.municipality, (m: any) => m.id === data.municipality,
)?.name; )?.name;
@@ -94,7 +95,7 @@ export function TrainingViewModal({
</Card> </Card>
); );
const BooleanBadge = ({ value }: { value?: boolean }) => ( const BooleanBadge = ({ value }: { value?: boolean | null }) => (
<Badge variant={value ? 'default' : 'secondary'}> <Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Sí' : 'No'} {value ? 'Sí' : 'No'}
</Badge> </Badge>
@@ -127,10 +128,7 @@ export function TrainingViewModal({
<div className="space-y-8"> <div className="space-y-8">
{/* 1. Datos de la Visita */} {/* 1. Datos de la Visita */}
<Section title="Datos de la Visita"> <Section title="Datos de la Visita">
<DetailItem <DetailItem label="Coordinador" value={data.coorFullName} />
label="Coordinador"
value={`${data.firstname} ${data.lastname}`}
/>
<DetailItem label="Teléfono Coord." value={data.coorPhone} /> <DetailItem label="Teléfono Coord." value={data.coorPhone} />
<DetailItem <DetailItem
label="Fecha Visita" label="Fecha Visita"
@@ -207,7 +205,11 @@ export function TrainingViewModal({
className="gap-2" className="gap-2"
> >
<a <a
href={data.ospGoogleMapsLink} href={
data.ospGoogleMapsLink.startsWith('http')
? data.ospGoogleMapsLink
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(data.ospGoogleMapsLink)}`
}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@@ -227,74 +229,36 @@ export function TrainingViewModal({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5" /> <Package className="h-5 w-5" />
Productos y Mano de Obra Productos Registrados
<Badge variant="secondary" className="ml-2"> <Badge variant="secondary" className="ml-2">
{data.productList?.length || 0} {data.productList?.length || 0}
</Badge> </Badge>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4"> <CardContent className="grid gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.productList?.map((prod: any, idx: number) => ( {data.productList?.map((prod: any, idx: number) => (
<div <div
key={idx} key={idx}
className="bg-muted/40 p-4 rounded-lg border text-sm" className="bg-muted/40 p-4 rounded-lg border text-sm"
> >
<div className="flex justify-between items-start mb-2"> <h4 className="font-bold text-base text-primary mb-2">
<h4 className="font-bold text-base text-primary">
{prod.productName}
</h4>
<Badge variant="outline">
Mano de obra:{' '}
{Number(prod.menCount || 0) +
Number(prod.womenCount || 0)}
</Badge>
</div>
<p className="text-muted-foreground mb-3">
{prod.description} {prod.description}
</p> </h4>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-3">
<DetailItem label="Diario" value={prod.dailyCount} /> <DetailItem label="Diario" value={prod.dailyCount} />
<DetailItem label="Semanal" value={prod.weeklyCount} />
<DetailItem label="Mensual" value={prod.monthlyCount} />
<DetailItem <DetailItem
label="Hombres / Mujeres" label="Semanal"
value={`${prod.menCount || 0} / ${prod.womenCount || 0}`} value={prod.weeklyCount}
/>
<DetailItem
label="Mensual"
value={prod.monthlyCount}
/> />
</div> </div>
{/* Detalles de distribución si existen */}
{(prod.internalQuantity || prod.externalQuantity) && (
<>
<Separator className="my-2" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{prod.internalQuantity && (
<div>
<span className="text-xs font-bold text-muted-foreground block mb-1">
DISTRIBUCIÓN INTERNA
</span>
<p>Cant: {prod.internalQuantity}</p>
<p className="text-xs text-muted-foreground">
{prod.internalDescription}
</p>
</div>
)}
{prod.externalQuantity && (
<div>
<span className="text-xs font-bold text-muted-foreground block mb-1">
EXPORTACIÓN ({prod.externalCountry})
</span>
<p>Cant: {prod.externalQuantity}</p>
<p className="text-xs text-muted-foreground">
{prod.externalDescription}
</p>
</div>
)}
</div>
</>
)}
</div> </div>
))} ))}
</div>
{(!data.productList || data.productList.length === 0) && ( {(!data.productList || data.productList.length === 0) && (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">
No hay productos registrados. No hay productos registrados.
@@ -303,6 +267,64 @@ export function TrainingViewModal({
</CardContent> </CardContent>
</Card> </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 */} {/* EQUIPAMIENTO Y PRODUCCIÓN */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card> <Card>
@@ -360,7 +382,9 @@ export function TrainingViewModal({
{mat.supplyType} {mat.supplyType}
</p> </p>
</div> </div>
<Badge variant="secondary">Cant: {mat.quantity}</Badge> <Badge variant="secondary">
Cant: {mat.quantity} {mat.unit}
</Badge>
</div> </div>
))} ))}
{(!data.productionList || {(!data.productionList ||
@@ -414,12 +438,12 @@ export function TrainingViewModal({
label="Teléfono" label="Teléfono"
value={data.ospResponsiblePhone} value={data.ospResponsiblePhone}
/> />
<DetailItem label="Email" value={data.ospResponsibleEmail} /> {/* <DetailItem label="Email" value={data.ospResponsibleEmail} />
<DetailItem <DetailItem
label="Carga Familiar" label="Carga Familiar"
value={data.familyBurden} value={data.familyBurden}
/> />
<DetailItem label="Hijos" value={data.numberOfChildren} /> <DetailItem label="Hijos" value={data.numberOfChildren} /> */}
</Section> </Section>
</div> </div>
@@ -463,7 +487,7 @@ export function TrainingViewModal({
onClick={() => setSelectedImage(photo)} onClick={() => setSelectedImage(photo)}
> >
<img <img
src={`${process.env.NEXT_PUBLIC_API_URL}${photo}`} src={`${photo}`}
alt={`Evidencia ${idx + 1}`} alt={`Evidencia ${idx + 1}`}
className="object-cover w-full h-full" className="object-cover w-full h-full"
/> />
@@ -513,7 +537,7 @@ export function TrainingViewModal({
</Button> </Button>
{selectedImage && ( {selectedImage && (
<img <img
src={`${process.env.NEXT_PUBLIC_API_URL}${selectedImage}`} src={`${selectedImage}`}
alt="Vista ampliada" alt="Vista ampliada"
className="max-w-full max-h-[90vh] object-contain rounded-md" className="max-w-full max-h-[90vh] object-contain rounded-md"
/> />

View File

@@ -28,7 +28,7 @@ export const ACTIVIDAD_PRINCIPAL = {
PATIOS_PRODUCTIVOS: 'PATIOS PRODUCTIVOS O CONUCOS', PATIOS_PRODUCTIVOS: 'PATIOS PRODUCTIVOS O CONUCOS',
TRANSFORMACION_MATERIA: 'TRANSFORMACION DE LA MATERIA PRIMA', TRANSFORMACION_MATERIA: 'TRANSFORMACION DE LA MATERIA PRIMA',
TEXTIL: 'TALLER DE COFECCION TEXTIL', TEXTIL: 'TALLER DE COFECCION TEXTIL',
CONSTRUCCION: 'CONSTRUCION', CONSTRUCCION: 'CONSTRUCCION',
BIENES_SERVICIOS: 'OFRECER PRODUCTOS DE BIENES Y SERVICIOS', BIENES_SERVICIOS: 'OFRECER PRODUCTOS DE BIENES Y SERVICIOS',
VISITAS_GUIADAS: 'VISITAS GUIADAS', VISITAS_GUIADAS: 'VISITAS GUIADAS',
ALOJAMIENTO: 'ALOJAMIENTO', ALOJAMIENTO: 'ALOJAMIENTO',
@@ -128,6 +128,8 @@ export const ACTIVIDAD_PRODUCTIVA_MAP: Record<string, string[]> = {
'ELABORACION DE ACEITE COMESTIBLE', 'ELABORACION DE ACEITE COMESTIBLE',
'FABRICA DE HIELO', 'FABRICA DE HIELO',
'ELABORACION DE PAPELON', 'ELABORACION DE PAPELON',
'TORREFACTORA DE CÁFE',
'ESPULPADORA DE TOMATES Y FRUTAS',
'ARTESANIAS', 'ARTESANIAS',
], ],
[ACTIVIDAD_PRINCIPAL.TEXTIL]: [ [ACTIVIDAD_PRINCIPAL.TEXTIL]: [

View File

@@ -3,82 +3,78 @@ import { z } from 'zod';
// 1. Definimos el esquema de un item individual de la lista de productos // 1. Definimos el esquema de un item individual de la lista de productos
// Basado en los campos que usaste en ProductActivityList // Basado en los campos que usaste en ProductActivityList
const productItemSchema = z.object({ const productItemSchema = z.object({
productName: z.string(), description: z.string().optional().nullable(),
description: z.string().optional(), dailyCount: z.coerce.string().or(z.number()).optional().nullable(),
dailyCount: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs weeklyCount: z.coerce.string().or(z.number()).optional().nullable(),
weeklyCount: z.coerce.string().or(z.number()).optional(), monthlyCount: z.coerce.string().or(z.number()).optional().nullable(),
monthlyCount: z.coerce.string().or(z.number()).optional(),
// Distribución Interna
internalState: z.number().optional(),
internalMunicipality: z.number().optional(),
internalParish: z.number().optional(),
internalDescription: z.string().optional(),
internalQuantity: z.coerce.string().or(z.number()).optional(),
// Distribución Externa
externalCountry: z.string().optional(),
externalState: z.number().optional(),
externalMunicipality: z.number().optional(),
externalParish: z.number().optional(),
externalCity: z.string().optional(),
externalDescription: z.string().optional(),
externalQuantity: z.coerce.string().or(z.number()).optional(),
// Mano de obra
womenCount: z.coerce.string().or(z.number()).optional(),
menCount: z.coerce.string().or(z.number()).optional(),
}); });
const productionItemSchema = z.object({ const productionItemSchema = z.object({
rawMaterial: z.string(), supplyType: z.string().optional().nullable(),
supplyType: z.string().optional(), quantity: z.coerce.string().or(z.number()).optional().nullable(),
quantity: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs unit: z.string().min(1, { message: 'Unidad es requerida' }).nullable(),
}); });
const equipmentItemSchema = z.object({ const equipmentItemSchema = z.object({
machine: z.string(), machine: z.string().nullable(),
specifications: z.string().optional(), quantity: z.coerce.string().or(z.number()).optional().nullable(),
quantity: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs
}); });
export const trainingSchema = z.object({ export const trainingSchema = z.object({
//Datos de la visita //Datos de la visita
id: z.number().optional(), id: z.number().optional(),
firstname: z.string().min(1, { message: 'Nombre es requerido' }), coorFullName: z
lastname: z.string().min(1, { message: 'Apellido es requerido' }), .string()
coorPhone: z.string().optional().nullable(), .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 visitDate: z
.string() .string()
.min(1, { message: 'Fecha y hora de visita es requerida' }), .min(1, { message: 'Fecha y hora de visita es requerida' }),
//Datos de la organización socioproductiva (OSP) //Datos de la organización socioproductiva (OSP)
ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }), ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
ecoSector: z.string().optional().or(z.literal('')), ecoSector: z.string({ message: 'Sector Económico es requerido' }),
productiveSector: z.string().optional().or(z.literal('')), productiveSector: z.string({ message: 'Sector Productivo es requerido' }),
centralProductiveActivity: z.string().optional().or(z.literal('')), centralProductiveActivity: z.string({
mainProductiveActivity: z.string().optional().or(z.literal('')), message: 'Actividad Central Productiva es requerido',
productiveActivity: z }),
.string() mainProductiveActivity: z.string({
.min(1, { message: 'Actividad productiva es requerida' }), message: 'Actividad Productiva Principal es requerida',
ospRif: z.string().optional().or(z.literal('')), }),
ospName: z.string().min(1, { message: 'Nombre de la OSP es requerido' }), productiveActivity: z.string({
message: 'Actividad Productiva es requerida',
}),
ospRif: z.string().optional().or(z.literal('')).nullable(),
ospName: z.string().optional().or(z.literal('')).nullable(),
companyConstitutionYear: z.coerce companyConstitutionYear: z.coerce
.number() .number()
.min(1900, { message: 'Año inválido' }), .min(1900, { message: 'Año inválido' })
.nullable(),
currentStatus: z currentStatus: z
.string() .string()
.min(1, { message: 'Estatus actual es requerido' }) .min(1, { message: 'Estatus actual es requerido' })
.default('ACTIVA'), .default('ACTIVA'),
infrastructureMt2: z.string().optional().or(z.literal('')), infrastructureMt2: z.string({ message: 'Infraestructura es requerida' }),
hasTransport: z hasTransport: z
.preprocess((val) => val === 'true' || val === true, z.boolean()) .preprocess(
.optional(), (val) => val === 'true' || val === true || val === 1 || val === '1',
structureType: z.string().optional().or(z.literal('')), z.boolean(),
)
.optional()
.nullable()
.default(false),
structureType: z.string({ message: 'Tipo de estructura es requerido' }),
isOpenSpace: z isOpenSpace: z
.preprocess((val) => val === 'true' || val === true, z.boolean()) .preprocess(
.optional(), (val) => val === 'true' || val === true || val === 1 || val === '1',
paralysisReason: z.string().optional().default(''), z.boolean(),
)
.optional()
.nullable()
.default(false),
paralysisReason: z.string().optional().nullable(),
//Datos del Equipamiento //Datos del Equipamiento
equipmentList: z.array(equipmentItemSchema).optional().default([]), equipmentList: z.array(equipmentItemSchema).optional().default([]),
@@ -89,37 +85,84 @@ export const trainingSchema = z.object({
// Datos de Actividad Productiva // Datos de Actividad Productiva
productList: z.array(productItemSchema).optional().default([]), 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 //Detalles de la ubicación
ospAddress: z ospAddress: z
.string() .string()
.min(1, { message: 'Dirección de la OSP es requerida' }), .min(1, { message: 'Dirección de la OSP es requerida' }),
ospGoogleMapsLink: z.string().optional().or(z.literal('')), ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
communeName: z.string().optional().or(z.literal('')), communeName: z
siturCodeCommune: z.string().optional().or(z.literal('')), .string()
communeRif: z.string().optional().or(z.literal('')), .min(1, { message: 'Nombre de la comuna es requerida' }),
communeSpokespersonName: z.string().optional().or(z.literal('')), siturCodeCommune: z
communeSpokespersonCedula: z.string().optional().or(z.literal('')), .string()
communeSpokespersonRif: z.string().optional().or(z.literal('')), .min(1, { message: 'Código SITUR de la comuna es requerida' }),
communeSpokespersonPhone: z.string().optional().or(z.literal('')), 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 communeEmail: z
.string() .string()
.email({ message: 'Correo electrónico de la Comuna inválido' }) .email({ message: 'Correo electrónico de la Comuna inválido' })
.optional() .optional()
.or(z.literal('')), .or(z.literal(''))
.nullable(),
communalCouncil: z communalCouncil: z
.string() .string()
.min(1, { message: 'Consejo Comunal es requerido' }), .min(1, { message: 'Consejo Comunal es requerido' }),
siturCodeCommunalCouncil: z.string().optional().or(z.literal('')), siturCodeCommunalCouncil: z
communalCouncilRif: z.string().optional().or(z.literal('')), .string()
communalCouncilSpokespersonName: z.string().optional().or(z.literal('')), .min(1, { message: 'Código SITUR del Consejo Comunal es requerido' }),
communalCouncilSpokespersonCedula: z.string().optional().or(z.literal('')), communalCouncilRif: z
communalCouncilSpokespersonRif: z.string().optional().or(z.literal('')), .string()
communalCouncilSpokespersonPhone: z.string().optional().or(z.literal('')), .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 communalCouncilEmail: z
.string() .string()
.email({ message: 'Correo electrónico del Consejo Comunal inválido' }) .email({ message: 'Correo electrónico del Consejo Comunal inválido' })
.optional() .optional()
.or(z.literal('')), .or(z.literal(''))
.nullable(),
//Datos del Responsable OSP //Datos del Responsable OSP
ospResponsibleCedula: z ospResponsibleCedula: z
@@ -128,46 +171,152 @@ export const trainingSchema = z.object({
ospResponsibleFullname: z ospResponsibleFullname: z
.string() .string()
.min(1, { message: 'Nombre del responsable es requerido' }), .min(1, { message: 'Nombre del responsable es requerido' }),
ospResponsibleRif: z ospResponsibleRif: z.string().optional().nullable(),
.string() civilState: z.string().optional().nullable(),
.min(1, { message: 'RIF del responsable es requerido' }),
civilState: z.string().min(1, { message: 'Estado civil es requerido' }),
ospResponsiblePhone: z ospResponsiblePhone: z
.string() .string()
.min(1, { message: 'Teléfono del responsable es requerido' }), .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 ospResponsibleEmail: z
.string() .string()
.email({ message: 'Correo electrónico inválido' }), .email({ message: 'Correo electrónico inválido' })
familyBurden: z.coerce .optional()
.number() .or(z.literal(''))
.min(0, { message: 'Carga familiar requerida' }), .nullable(),
numberOfChildren: z.coerce
.number() familyBurden: z.coerce.number().optional(),
.min(0, { message: 'Número de hijos requerido' }), numberOfChildren: z.coerce.number().optional(),
//Datos adicionales //Datos adicionales
generalObservations: z.string().optional().default(''), generalObservations: z.string().optional().nullable(),
//IMAGENES //IMAGENES
files: z.any().optional(), files: z.any().optional(),
//no se envia la backend al crear ni editar el formulario //no se envia la backend al crear ni editar el formulario
state: z.number().optional().nullable(), state: z.number({ message: 'El estado es requerido' }).nullable(),
municipality: z.number().optional().nullable(), municipality: z.number({ message: 'Municipio es requerido' }).nullable(),
parish: z.number().optional().nullable(), parish: z.number({ message: 'Parroquia es requerido' }).nullable(),
coorState: z.number().optional().nullable(), coorState: z.number().optional().nullable(),
coorMunicipality: z.number().optional().nullable(), coorMunicipality: z.number().optional().nullable(),
coorParish: z.number().optional().nullable(), coorParish: z.number().optional().nullable(),
photo1: z.string().optional().nullable(), photo1: z.string().optional().nullable(),
photo2: z.string().optional().nullable(), photo2: z.string().optional().nullable(),
photo3: 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 type TrainingSchema = z.infer<typeof trainingSchema>;
export const getTrainingSchema = z.object({
//Datos de la visita
id: z.number().optional(),
coorFullName: z.string(),
coorPhone: z.string(),
visitDate: z.string(),
//Datos de la organización socioproductiva (OSP)
ospType: z.string(),
ecoSector: z.string(),
productiveSector: z.string(),
centralProductiveActivity: z.string(),
mainProductiveActivity: z.string(),
productiveActivity: z.string(),
ospRif: z.string().optional().or(z.literal('')).nullable(),
ospName: z.string().optional().or(z.literal('')).nullable(),
companyConstitutionYear: z.coerce.number(),
currentStatus: z.string(),
infrastructureMt2: z.string(),
hasTransport: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.nullable()
.default(false),
structureType: z.string(),
isOpenSpace: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.nullable()
.default(false),
paralysisReason: z.string().optional().nullable(),
//Datos del Equipamiento
equipmentList: z.array(equipmentItemSchema).optional().default([]),
//Datos de Producción
productionList: z.array(productionItemSchema).optional().default([]),
// Datos de Actividad Productiva
productList: z.array(productItemSchema).optional().default([]),
// Distribución y Exportación
internalDistributionZone: z.string(),
isExporting: z
.preprocess(
(val) => val === 'true' || val === true || val === 1 || val === '1',
z.boolean(),
)
.optional()
.default(false),
externalCountry: z.string().optional().nullable(),
externalCity: z.string().optional().nullable(),
externalDescription: z.string().optional().nullable(),
externalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
externalUnit: z.string().optional().nullable(),
// Mano de obra
womenCount: z.coerce.number(),
menCount: z.coerce.number(),
//Detalles de la ubicación
ospAddress: z.string(),
ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
communeName: z.string(),
siturCodeCommune: z.string(),
communeRif: z.string().or(z.literal('')).nullable(),
communeSpokespersonName: z.string().or(z.literal('')).nullable(),
communeSpokespersonPhone: z.string(),
communeEmail: z.string().optional().or(z.literal('')).nullable(),
communalCouncil: z.string(),
siturCodeCommunalCouncil: z.string(),
communalCouncilRif: z.string().optional(),
communalCouncilSpokespersonName: z.string(),
communalCouncilSpokespersonPhone: z.string(),
communalCouncilEmail: z.string(),
//Datos del Responsable OSP
ospResponsibleCedula: z.string(),
ospResponsibleFullname: z.string(),
ospResponsibleRif: z.string().optional().nullable(),
civilState: z.string().optional().nullable(),
ospResponsiblePhone: z.string(),
ospResponsibleEmail: z.string(),
familyBurden: z.coerce.number().optional(),
numberOfChildren: z.coerce.number().optional(),
//Datos adicionales
generalObservations: z.string().optional().nullable(),
//no se envia la backend al crear ni editar el formulario
state: z.number().nullable(),
municipality: z.number().nullable(),
parish: z.number().nullable(),
coorState: z.number().optional().nullable(),
coorMunicipality: z.number().optional().nullable(),
coorParish: z.number().optional().nullable(),
photo1: z.string().optional().nullable(),
photo2: z.string().optional().nullable(),
photo3: z.string().optional().nullable(),
createdBy: z.number().optional().nullable(),
updatedBy: z.number().optional().nullable(),
created_at: z.string().optional().nullable(),
updated_at: z.string().optional().nullable(),
});
export const trainingApiResponseSchema = z.object({ export const trainingApiResponseSchema = z.object({
message: z.string(), message: z.string(),
data: z.array(trainingSchema), data: z.array(getTrainingSchema),
meta: z.object({ meta: z.object({
page: z.number(), page: z.number(),
limit: z.number(), limit: z.number(),

View File

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

View File

@@ -1,11 +1,10 @@
// lib/auth.config.ts // lib/auth.config.ts
import { SignInAction } from '@/feactures/auth/actions/login-action'; 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 { 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'; import CredentialProvider from 'next-auth/providers/credentials';
// Define los tipos para tus respuestas de SignInAction // Define los tipos para tus respuestas de SignInAction
interface SignInSuccessResponse { interface SignInSuccessResponse {
message: string; message: string;
@@ -58,8 +57,10 @@ const authConfig: NextAuthConfig = {
// **NUEVO: Manejar el caso `null` primero** // **NUEVO: Manejar el caso `null` primero**
if (response === null) { if (response === null) {
console.error("SignInAction returned null, indicating a potential issue before API call or generic error."); console.error(
throw new CredentialsSignin("Error de inicio de sesión inesperado."); '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 // 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 response.type === 'UNKNOWN_ERROR') // Incluye todos los tipos de error posibles
) { ) {
// Si es un error, lánzalo. Este camino termina aquí. // 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)) { if (!('user' in response)) {
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null, // 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'. // no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
// Es un caso de respuesta inesperada del API. // Es un caso de respuesta inesperada del API.
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'."); console.error(
throw new CredentialsSignin("Error en el formato de la respuesta del servidor."); "Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.",
);
throw new CredentialsSignin(
'Error en el formato de la respuesta del servidor.',
);
} }
return { return {
@@ -89,11 +94,7 @@ const authConfig: NextAuthConfig = {
role: response?.user.rol ?? [], // Add role array role: response?.user.rol ?? [], // Add role array
access_token: response?.tokens.access_token ?? '', access_token: response?.tokens.access_token ?? '',
access_expire_in: response?.tokens.access_expire_in ?? 0, 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 signIn: '/', //sigin page
}, },
callbacks: { callbacks: {
async jwt({ token, user }:{ async jwt({ token, user }: { user: User; token: any }) {
user: User
token: any
}) {
// 1. Manejar el inicio de sesión inicial // 1. Manejar el inicio de sesión inicial
// El `user` solo se proporciona en el primer inicio de sesión. // El `user` solo se proporciona en el primer inicio de sesión.
if (user) { if (user) {
@@ -117,64 +114,14 @@ const authConfig: NextAuthConfig = {
role: user.role, role: user.role,
access_token: user.access_token, access_token: user.access_token,
access_expire_in: user.access_expire_in, 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}
const res = await resfreshTokenAction(refresh_token);
// console.log('res', res);
if (!res || !res.tokens) {
throw new Error('Fallo en la respuesta de la API de refresco.');
}
// 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; return token;
} catch (error) {
console.error("Error al renovar el token: ", error);
return null; // Fallo al renovar, forzar logout
}
}, },
async session({ session, token }: { session: Session; token: any }) { async session({ session, token }: { session: Session; token: DefaultJWT }) {
session.access_token = token.access_token as string; session.access_token = token.access_token as string;
session.access_expire_in = token.access_expire_in as number; 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 = { session.user = {
id: token.id as number, id: token.id as number,
username: token.username as string, username: token.username as string,
@@ -185,7 +132,18 @@ const authConfig: NextAuthConfig = {
return session; 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; } satisfies NextAuthConfig;
export default authConfig; export default authConfig;

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ declare module 'next-auth' {
interface Session extends DefaultSession { interface Session extends DefaultSession {
access_token: string; access_token: string;
access_expire_in: number; access_expire_in: number;
refresh_token: string;
refresh_expire_in: number;
user: { user: {
id: number; id: number;
username: string; username: string;
@@ -29,8 +27,6 @@ declare module 'next-auth' {
}>; }>;
access_token: string; access_token: string;
access_expire_in: number; access_expire_in: number;
refresh_token: string;
refresh_expire_in: number;
} }
} }
@@ -46,7 +42,5 @@ declare module 'next-auth/jwt' {
}>; }>;
access_token: string; access_token: string;
access_expire_in: number; access_expire_in: number;
refresh_token: string;
refresh_expire_in: number;
} }
} }

View File

@@ -8,18 +8,18 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: hsl(0 0% 100%); --background: hsl(51, 76%, 97%);
--foreground: hsl(240 10% 3.9%); --foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%); --card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%); --card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%); --popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%); --popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(3, 85%, 32%); --primary: hsl(17, 86%, 45%);
--primary-foreground: hsl(355.7 100% 97.3%); --primary-foreground: hsl(355.7 100% 97.3%);
--secondary: hsl(240 4.8% 95.9%); --secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%); --secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%); --muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%); --muted-foreground: hsl(240, 2%, 31%);
--accent: hsl(240 4.8% 95.9%); --accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%); --accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%); --destructive: hsl(0 84.2% 60.2%);
@@ -33,13 +33,13 @@
--chart-3: hsl(197 37% 24%); --chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%); --chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%); --chart-5: hsl(27 87% 67%);
--sidebar-background: hsl(0 0% 98%); --sidebar-background: hsl(27, 92%, 90%);
--sidebar-foreground: hsl(240 5.3% 26.1%); --sidebar-foreground: hsl(0, 0%, 1%);
--sidebar-primary: hsl(240 5.9% 10%); --sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%); --sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%); --sidebar-accent: hsl(24, 82%, 67%);
--sidebar-accent-foreground: hsl(240 5.9% 10%); --sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%); --sidebar-border: hsl(20, 13%, 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%); --sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%); --sidebar: hsl(0 0% 98%);
} }

View File

@@ -0,0 +1,184 @@
@import 'tailwindcss';
@source './components/ui';
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(3, 85%, 32%);
--primary-foreground: hsl(355.7 100% 97.3%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(3, 85%, 32%);
--radius: 0.7rem;
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(20 14.3% 4.1%);
--foreground: hsl(0 0% 95%);
--card: hsl(240 5.9% 10%);
--card-foreground: hsl(0 0% 95%);
--popover: hsl(0 0% 9%);
--popover-foreground: hsl(0 0% 95%);
--primary: hsl(3, 85%, 32%);
--primary-foreground: hsl(180, 33%, 99%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 15%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(12 6.5% 15.1%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 85.7% 97.3%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(3, 85%, 32%);
/* --chart-1: hsl(220 70% 50%); */
--chart-1: hsl(22 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar-background);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground overscroll-none;
/* font-feature-settings: "rlig" 1, "calt" 1; */
font-synthesis-weight: none;
text-rendering: optimizeLegibility;
}
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
[data-wrapper] {
@apply min-[1800px]:border-t;
}
}
/* Custom scrollbar styling. Thanks @pranathiperii. */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 5px;
background: hsl(var(--border));
}
* {
scrollbar-color: hsl(var(--border)) transparent;
scrollbar-width: thin;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}