19 Commits

Author SHA1 Message Date
f1bdce317f Exportar excel con imagen y ahora guarda las imagenes como .png 2026-02-05 18:09:05 -04:00
63c39e399e corrreciones al formulario de las osp 2026-02-03 14:19:57 -04:00
26fb849fa3 Exportar OSP en excel con formato especifico (falta img y datos que no estan en el formulario) 2026-02-01 16:58:50 -04:00
2566e5e9a7 correciones visuales al formulario 2026-01-29 09:31:00 -04:00
8efe595f73 correciones al formulario osp 2026-01-28 22:42:19 -04:00
d2908f1e4c agregado los campos de distribucion (interna y externa) 2026-01-28 15:54:26 -04:00
69843e9e68 Algunos campos agregados/eliminados 2026-01-28 13:50:10 -04:00
5c080c6d32 datos de visita agregada 2026-01-26 14:57:23 -04:00
08a5567d60 mejoras al formulario de registro organizaciones productivas 2026-01-22 14:28:24 -04:00
69b3aab02a Se cambio en el modulo de editar producto el tipo de input a number 2025-12-15 13:12:10 -04:00
b8b11259cd Merge branch 'main' of ssh://git.fondemi.gob.ve:222/Fondemi/sistema_base 2025-12-15 13:06:33 -04:00
6482e692b3 Se cambio del modulo de producto-inventario el $ por Bs. 2025-12-15 13:04:00 -04:00
c1d1626e9e correcion routes web 2025-12-15 12:11:54 -04:00
824685723b correcion training 2025-12-15 11:58:41 -04:00
127e3b0e7a corregido erro codigo repetido auth 2025-12-15 11:20:33 -04:00
ee499abcf9 aceptado cambios auth 2025-12-15 10:53:36 -04:00
949d54e590 correciones de compilacion 2025-12-15 10:04:38 -04:00
28d51a9c00 correccion en un and con mala sintaxi en la sesiones 2025-10-09 12:01:13 -04:00
c1d4a40244 refresh token esta vez si (espero) 2025-10-09 11:25:46 -04:00
72 changed files with 20177 additions and 1600 deletions

View File

@@ -15,5 +15,5 @@ DATABASE_URL="postgresql://postgres:local**@localhost:5432/caja_ahorro" #url con
#Mail Configuration #Mail Configuration
MAIL_HOST=gmail MAIL_HOST=gmail
MAIL_USERNAME= MAIL_USERNAME="123"
MAIL_PASSWORD= MAIL_PASSWORD="123"

3
apps/api/.gitignore vendored
View File

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

View File

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

View File

@@ -42,6 +42,7 @@
"@nestjs/platform-express": "11.0.0", "@nestjs/platform-express": "11.0.0",
"dotenv": "16.5.0", "dotenv": "16.5.0",
"drizzle-orm": "0.40.0", "drizzle-orm": "0.40.0",
"exceljs": "^4.4.0",
"express": "5.1.0", "express": "5.1.0",
"joi": "17.13.3", "joi": "17.13.3",
"moment": "2.30.1", "moment": "2.30.1",
@@ -49,7 +50,8 @@
"pg": "8.13.3", "pg": "8.13.3",
"pino-pretty": "13.0.0", "pino-pretty": "13.0.0",
"reflect-metadata": "0.2.0", "reflect-metadata": "0.2.0",
"rxjs": "7.8.1" "rxjs": "7.8.1",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,55 @@
"when": 1764883378610, "when": 1764883378610,
"tag": "0009_eminent_ares", "tag": "0009_eminent_ares",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769097895095,
"tag": "0010_dashing_bishop",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1769618795008,
"tag": "0011_magical_thundra",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769621656400,
"tag": "0012_sudden_venus",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1769629815868,
"tag": "0013_cuddly_night_nurse",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1769646908602,
"tag": "0014_deep_meteorite",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1769648728698,
"tag": "0015_concerned_wild_pack",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1769653021994,
"tag": "0016_silent_tag",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,9 +1,8 @@
import { sql } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core'; import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps'; import { timestamps } from '../timestamps';
import { users } from './auth'; import { users } from './auth';
import { states, municipalities, parishes } from './general'; import { municipalities, parishes, states } from './general';
// Tabla surveys // Tabla surveys
export const surveys = t.pgTable( export const surveys = t.pgTable(
@@ -19,9 +18,7 @@ export const surveys = t.pgTable(
...timestamps, ...timestamps,
}, },
(surveys) => ({ (surveys) => ({
surveysIndex: t surveysIndex: t.index('surveys_index_00').on(surveys.title),
.index('surveys_index_00')
.on(surveys.title),
}), }),
); );
@@ -49,52 +46,113 @@ export const answersSurveys = t.pgTable(
export const trainingSurveys = t.pgTable( export const trainingSurveys = t.pgTable(
'training_surveys', 'training_surveys',
{ {
// Datos basicos // === 1. IDENTIFICADORES Y DATOS DE VISITA ===
id: t.serial('id').primaryKey(), id: t.serial('id').primaryKey(),
firstname: t.text('firstname').notNull(), firstname: t.text('firstname').notNull(),
lastname: t.text('lastname').notNull(), lastname: t.text('lastname').notNull(),
visitDate: t.timestamp('visit_date').notNull(), visitDate: t.timestamp('visit_date').notNull(),
// ubicacion coorPhone: t.text('coor_phone'),
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }), // === 2. UBICACIÓN (Claves Foráneas - Nullables) ===
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }), state: t
.integer('state')
.references(() => states.id, { onDelete: 'set null' }),
municipality: t
.integer('municipality')
.references(() => municipalities.id, { onDelete: 'set null' }),
parish: t
.integer('parish')
.references(() => parishes.id, { onDelete: 'set null' }),
// === 3. DATOS DE LA OSP (Organización Socioproductiva) ===
ospType: t.text('osp_type').notNull(), // UPF, EPS, etc.
ecoSector: t.text('eco_sector').notNull().default(''),
productiveSector: t.text('productive_sector').notNull().default(''),
centralProductiveActivity: t
.text('central_productive_activity')
.notNull()
.default(''),
mainProductiveActivity: t
.text('main_productive_activity')
.notNull()
.default(''),
productiveActivity: t.text('productive_activity').notNull(),
ospRif: t.text('osp_rif').notNull(),
ospName: t.text('osp_name').notNull(),
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
currentStatus: t.text('current_status').notNull().default('ACTIVA'),
infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''),
hasTransport: t.boolean('has_transport').notNull().default(false),
structureType: t.text('structure_type').notNull().default(''),
isOpenSpace: t.boolean('is_open_space').notNull().default(false),
paralysisReason: t.text('paralysis_reason'),
equipmentList: t.jsonb('equipment_list').notNull().default([]),
productionList: t.jsonb('production_list').notNull().default([]),
productList: t.jsonb('product_list').notNull().default([]),
ospAddress: t.text('osp_address').notNull(),
ospGoogleMapsLink: t.text('osp_google_maps_link').notNull().default(''),
communeName: t.text('commune_name').notNull().default(''),
siturCodeCommune: t.text('situr_code_commune').notNull(), siturCodeCommune: t.text('situr_code_commune').notNull(),
communeRif: t.text('commune_rif').notNull().default(''),
communeSpokespersonName: t
.text('commune_spokesperson_name')
.notNull()
.default(''),
communeSpokespersonCedula: t
.text('commune_spokesperson_cedula')
.notNull()
.default(''),
communeSpokespersonRif: t
.text('commune_spokesperson_rif')
.notNull()
.default(''),
communeSpokespersonPhone: t
.text('commune_spokesperson_phone')
.notNull()
.default(''),
communeEmail: t.text('commune_email').notNull().default(''),
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(),
// datos del OSP (ORGANIZACIÓN SOCIOPRODUCTIVA) communalCouncilRif: t.text('communal_council_rif').notNull().default(''),
ospName: t.text('osp_name').notNull(), communalCouncilSpokespersonName: t
ospAddress: t.text('osp_address').notNull(), .text('communal_council_spokesperson_name')
ospRif: t.text('osp_rif').notNull(), .notNull()
ospType: t.text('osp_type').notNull(), .default(''),
productiveActivity: t.text('productive_activity').notNull(), communalCouncilSpokespersonCedula: t
financialRequirementDescription: t.text('financial_requirement_description').notNull(), .text('communal_council_spokesperson_cedula')
currentStatus: t.text('current_status').notNull(), .notNull()
companyConstitutionYear: t.integer('company_constitution_year').notNull(), .default(''),
producerCount: t.integer('producer_count').notNull(), communalCouncilSpokespersonRif: t
productDescription: t.text('product_description').notNull(), .text('communal_council_spokesperson_rif')
installedCapacity: t.text('installed_capacity').notNull(), .notNull()
operationalCapacity: t.text('operational_capacity').notNull(), .default(''),
// datos del responsable communalCouncilSpokespersonPhone: t
.text('communal_council_spokesperson_phone')
.notNull()
.default(''),
communalCouncilEmail: t
.text('communal_council_email')
.notNull()
.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').notNull(),
civilState: t.text('civil_state').notNull(),
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').notNull(),
civilState: t.text('civil_state').notNull(),
familyBurden: t.integer('family_burden').notNull(), familyBurden: t.integer('family_burden').notNull(),
numberOfChildren: t.integer('number_of_children').notNull(), numberOfChildren: t.integer('number_of_children').notNull(),
// datos adicionales generalObservations: t.text('general_observations'),
generalObservations: t.text('general_observations').notNull(), photo1: t.text('photo1'),
paralysisReason: t.text('paralysis_reason').notNull(), photo2: t.text('photo2'),
// fotos photo3: t.text('photo3'),
photo1: t.text('photo1').notNull(),
photo2: t.text('photo2').notNull(),
photo3: t.text('photo3').notNull(),
...timestamps, ...timestamps,
}, },
(trainingSurveys) => ({ (trainingSurveys) => ({
trainingSurveysIndex: t.index('training_surveys_index_00').on(trainingSurveys.firstname), trainingSurveysIndex: t
}) .index('training_surveys_index_00')
.on(trainingSurveys.firstname),
}),
); );
export const viewSurveys = t.pgView('v_surveys', { export const viewSurveys = t.pgView('v_surveys', {
@@ -103,6 +161,7 @@ export const viewSurveys = t.pgView('v_surveys', {
description: t.text('description'), description: t.text('description'),
created_at: t.timestamp('created_at'), created_at: t.timestamp('created_at'),
closingDate: t.date('closing_date'), closingDate: t.date('closing_date'),
targetAudience: t.varchar('target_audience') targetAudience: t.varchar('target_audience'),
}).as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys })
where published = true`); .as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);

View File

@@ -59,8 +59,13 @@ export class AuthController {
//@RequirePermissions('auth:refresh-token') //@RequirePermissions('auth:refresh-token')
async refreshToken(@Body() refreshTokenDto: any) { async refreshToken(@Body() refreshTokenDto: any) {
console.log('refreshTokenDto', refreshTokenDto);
const data = await this.authService.refreshToken(refreshTokenDto); const data = await this.authService.refreshToken(refreshTokenDto);
// console.log('data', data);
if (!data) return null; if (!data) return null;
return {tokens: data} return {tokens: data}

View File

@@ -4,8 +4,8 @@ import { Env, validateString } from '@/common/utils';
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto'; import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto'; import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto'; import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto'; import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface'; import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
import { import {
@@ -24,14 +24,14 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index'; import * as schema from 'src/database/index';
import { sessions, users, roles, usersRole } from 'src/database/index'; import { roles, sessions, users, usersRole } from 'src/database/index';
import { Session } from './interfaces/session.interface'; import { Session } from './interfaces/session.interface';
import * as bcrypt from 'bcryptjs';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -40,7 +40,7 @@ export class AuthService {
private readonly config: ConfigService<Env>, private readonly config: ConfigService<Env>,
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>, @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
private readonly mailService: MailService, private readonly mailService: MailService,
) { } ) {}
//Decode Tokens //Decode Tokens
// Método para decodificar el token y obtener los datos completos // Método para decodificar el token y obtener los datos completos
@@ -81,33 +81,43 @@ export class AuthService {
//Generate Tokens //Generate Tokens
async generateTokens(user: User): Promise<AuthTokensInterface> { async generateTokens(user: User): Promise<AuthTokensInterface> {
const accessTokenSecret = envs.access_token_secret ?? '';
const accessTokenExp = envs.access_token_expiration ?? '';
const refreshTokenSecret = envs.refresh_token_secret ?? '';
const refreshTokenExp = envs.refresh_token_expiration ?? '';
if (
!accessTokenSecret ||
!accessTokenExp ||
!refreshTokenSecret ||
!refreshTokenExp
) {
throw new Error('JWT environment variables are missing or invalid');
}
interface JwtPayload {
sub: number;
username: string;
}
const payload: JwtPayload = {
sub: Number(user?.id),
username: user.username ?? '',
};
const [access_token, refresh_token] = await Promise.all([ const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync( this.jwtService.signAsync(payload, {
{ secret: accessTokenSecret,
sub: user.id, expiresIn: accessTokenExp,
username: user.username, } as JwtSignOptions),
},
{ this.jwtService.signAsync(payload, {
secret: envs.access_token_secret, secret: refreshTokenSecret,
expiresIn: envs.access_token_expiration as any, expiresIn: refreshTokenExp,
}, } as JwtSignOptions),
),
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.refresh_token_secret,
expiresIn: envs.refresh_token_expiration as any,
},
),
]); ]);
return { return { access_token, refresh_token };
access_token,
refresh_token,
};
} }
//Generate OTP Code For Email Confirmation //Generate OTP Code For Email Confirmation
@@ -138,7 +148,8 @@ export class AuthService {
userId: parseInt(userId), userId: parseInt(userId),
expiresAt: sessionInput.expiresAt, expiresAt: sessionInput.expiresAt,
}); });
if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND); if (session.rowCount === 0)
throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
return 'Session created successfully'; return 'Session created successfully';
} }
@@ -197,7 +208,6 @@ export class AuthService {
//Sign In User Account //Sign In User Account
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> { async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
const user = await this.validateUser(dto); const user = await this.validateUser(dto);
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token); const decodeAccess = this.decodeToken(tokens.access_token);
@@ -263,11 +273,14 @@ export class AuthService {
//Refresh User Access Token //Refresh User Access Token
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> { async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
const { user_id, refresh_token } = dto; const secret = envs.refresh_token_secret;
// const user_id = 1; const { user_id, token } = dto;
const validation = await this.jwtService.verifyAsync(refresh_token, { console.log('secret', secret);
secret: envs.refresh_token_secret, console.log('refresh_token', token);
const validation = await this.jwtService.verifyAsync(token, {
secret,
}); });
if (!validation) throw new UnauthorizedException('Invalid refresh token'); if (!validation) throw new UnauthorizedException('Invalid refresh token');
@@ -276,10 +289,7 @@ export class AuthService {
.select() .select()
.from(sessions) .from(sessions)
.where( .where(
and( and(eq(sessions.userId, user_id), eq(sessions.sessionToken, token)),
eq(sessions.userId, user_id) &&
eq(sessions.sessionToken, dto.refresh_token),
),
); );
// console.log(session.length); // console.log(session.length);
@@ -313,25 +323,33 @@ export class AuthService {
.select({ .select({
id: users.id, id: users.id,
username: users.username, username: users.username,
email: users.email email: users.email,
}) })
.from(users) .from(users)
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email))); .where(
or(
eq(users.username, createUserDto.username),
eq(users.email, createUserDto.email),
),
);
if (data.length > 0) { if (data.length > 0) {
if (data[0].username === createUserDto.username) { if (data[0].username === createUserDto.username) {
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST); throw new HttpException(
'Username already exists',
HttpStatus.BAD_REQUEST,
);
} }
if (data[0].email === createUserDto.email) { if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST); throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
} }
} }
// Hash the password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10); const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction // Start a transaction
return await this.drizzle.transaction(async (tx) => { return await this.drizzle.transaction(async (tx) => {
// Hash the password
// Create the user // Create the user
const [newUser] = await tx const [newUser] = await tx
.insert(users) .insert(users)
@@ -352,6 +370,7 @@ export class AuthService {
// check if user role is admin // check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role; const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// check if user role is admin
// Assign role to user // Assign role to user
await tx.insert(usersRole).values({ await tx.insert(usersRole).values({
@@ -376,7 +395,6 @@ export class AuthService {
.where(eq(users.id, newUser.id)); .where(eq(users.id, newUser.id));
return userWithRole; return userWithRole;
}) });
} }
} }

View File

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

View File

@@ -1,140 +1,280 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, IsDateString, IsOptional } from 'class-validator'; import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsOptional,
IsString,
} from 'class-validator';
export class CreateTrainingDto { export class CreateTrainingDto {
@ApiProperty() // === 1. DATOS BÁSICOS ===
@IsString() @ApiProperty()
firstname: string; @IsString()
firstname: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
lastname: string; lastname: string;
@ApiProperty() @ApiProperty()
@IsDateString() @IsDateString()
visitDate: string; visitDate: string; // Llega como string ISO "2024-11-11T10:00"
@ApiProperty() @ApiProperty()
@IsString() @IsString()
productiveActivity: string; @IsOptional()
coorPhone?: string;
@ApiProperty() // === 2. DATOS OSP ===
@IsString() @ApiProperty()
financialRequirementDescription: string; @IsString()
ospName: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsString()
state: number; ospRif: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsString()
municipality: number; ospType: string; // 'UPF', etc.
@ApiProperty() @ApiProperty()
@IsInt() @IsString()
parish: number; productiveActivity: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
siturCodeCommune: string; currentStatus: string;
@ApiProperty() @ApiProperty()
@IsString() @IsInt()
communalCouncil: string; @Type(() => Number) // Convierte "2017" -> 2017
companyConstitutionYear: number;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
siturCodeCommunalCouncil: string; @IsOptional()
ospAddress: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospName: string; @IsOptional()
ospGoogleMapsLink?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospAddress: string; @IsOptional()
infrastructureMt2?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospRif: string; @IsOptional()
structureType?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsBoolean()
ospType: string; @IsOptional()
@Transform(({ value }) => value === 'true' || value === true) // Convierte "false" -> false
hasTransport?: boolean;
@ApiProperty() @ApiProperty()
@IsString() @IsBoolean()
currentStatus: string; @IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
isOpenSpace?: boolean;
@ApiProperty() @ApiProperty()
@IsInt() @IsString()
companyConstitutionYear: number; @IsOptional()
paralysisReason?: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsString()
producerCount: number; @IsOptional()
generalObservations?: string;
@ApiProperty() // === 3. SECTORES ===
@IsString() @ApiProperty()
productDescription: string; @IsString()
ecoSector: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
installedCapacity: string; productiveSector: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
operationalCapacity: string; centralProductiveActivity: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospResponsibleFullname: string; mainProductiveActivity: string;
@ApiProperty() // === 4. DATOS RESPONSABLE ===
@IsString() @ApiProperty()
ospResponsibleCedula: string; @IsString()
ospResponsibleFullname: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospResponsibleRif: string; ospResponsibleCedula: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospResponsiblePhone: string; ospResponsibleRif: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
ospResponsibleEmail: string; ospResponsiblePhone: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
civilState: string; ospResponsibleEmail: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsString()
familyBurden: number; civilState: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
numberOfChildren: number; @Type(() => Number) // Convierte "3" -> 3
familyBurden: number;
@ApiProperty() @ApiProperty()
@IsString() @IsInt()
generalObservations: string; @Type(() => Number)
numberOfChildren: number;
@ApiProperty() // === 5. COMUNA Y CONSEJO COMUNAL ===
@IsString() @ApiProperty()
photo1: string; @IsString()
siturCodeCommune: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
photo2: string; communeName: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
photo3: string; communeRif: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
paralysisReason: string; communeSpokespersonName: string;
@ApiProperty()
@IsString()
communeSpokespersonCedula: string;
@ApiProperty()
@IsString()
communeSpokespersonRif: string;
@ApiProperty()
@IsString()
communeSpokespersonPhone: string;
@ApiProperty()
@IsString()
communeEmail: string;
@ApiProperty()
@IsString()
communalCouncil: string;
@ApiProperty()
@IsString()
siturCodeCommunalCouncil: string;
@ApiProperty()
@IsString()
communalCouncilRif: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonName: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonCedula: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonRif: string;
@ApiProperty()
@IsString()
communalCouncilSpokespersonPhone: string;
@ApiProperty()
@IsString()
communalCouncilEmail: string;
// === 6. LISTAS (Arrays JSON) ===
// Reciben un string JSON '[{...}]' y lo convierten a Objeto JS real
@ApiProperty()
@IsOptional()
@IsArray()
@Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return [];
}
}
return value;
})
equipmentList?: any[];
@ApiProperty()
@IsOptional()
@IsArray()
@Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return [];
}
}
return value;
})
productionList?: any[];
@ApiProperty()
@IsOptional()
@IsArray()
@Transform(({ value }) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return [];
}
}
return value;
})
productList?: any[];
//ubicacion
@ApiProperty()
@IsString()
state: string;
@ApiProperty()
@IsString()
municipality: string;
@ApiProperty()
@IsString()
parish: string;
} }

View File

@@ -1,68 +1,144 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; import {
import { TrainingService } from './training.service'; Body,
import { CreateTrainingDto } from './dto/create-training.dto'; Controller,
import { UpdateTrainingDto } from './dto/update-training.dto'; Delete,
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; Get,
Param,
Patch,
Post,
Query,
Res,
UploadedFiles,
UseInterceptors,
StreamableFile,
Header
} from '@nestjs/common';
import { Readable } from 'stream';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from '../../common/dto/pagination.dto';
import { ImageProcessingPipe } from '../../common/pipes/image-processing.pipe';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
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) { }
@Get() // export training with excel
@ApiOperation({ summary: 'Get all training records with pagination and filters' }) @Public()
@ApiResponse({ status: 200, description: 'Return paginated training records.' }) @Get('export/:id')
async findAll(@Query() paginationDto: PaginationDto) { @ApiOperation({ summary: 'Export training with excel' })
const result = await this.trainingService.findAll(paginationDto); @ApiResponse({
return { status: 200,
message: 'Training records fetched successfully', description: 'Return training with excel.',
data: result.data, content: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { schema: { type: 'string', format: 'binary' } } }
meta: result.meta })
}; @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename=export_osp.xlsx')
async exportTemplate(@Param('id') id: string) {
if (!Number(id)) {
throw new Error('ID is required');
} }
const data = await this.trainingService.exportTemplate(Number(id));
return new StreamableFile(Readable.from([data]));
}
@Get('statistics') // get all training records
@ApiOperation({ summary: 'Get training statistics' }) @Get()
@ApiResponse({ status: 200, description: 'Return training statistics.' }) @ApiOperation({
async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) { summary: 'Get all training records with pagination and filters',
const data = await this.trainingService.getStatistics(filterDto); })
return { message: 'Training statistics fetched successfully', data }; @ApiResponse({
} status: 200,
description: 'Return paginated training records.',
})
async findAll(@Query() paginationDto: PaginationDto) {
const result = await this.trainingService.findAll(paginationDto);
return {
message: 'Training records fetched successfully',
data: result.data,
meta: result.meta,
};
}
@Get(':id') // get training statistics
@ApiOperation({ summary: 'Get a training record by ID' }) @Get('statistics')
@ApiResponse({ status: 200, description: 'Return the training record.' }) @ApiOperation({ summary: 'Get training statistics' })
@ApiResponse({ status: 404, description: 'Training record not found.' }) @ApiResponse({ status: 200, description: 'Return training statistics.' })
async findOne(@Param('id') id: string) { async getStatistics(@Query() filterDto: TrainingStatisticsFilterDto) {
const data = await this.trainingService.findOne(+id); const data = await this.trainingService.getStatistics(filterDto);
return { message: 'Training record fetched successfully', data }; return { message: 'Training statistics fetched successfully', data };
} }
@Post() // get training record by id
@ApiOperation({ summary: 'Create a new training record' }) @Get(':id')
@ApiResponse({ status: 201, description: 'Training record created successfully.' }) @ApiOperation({ summary: 'Get a training record by ID' })
async create(@Body() createTrainingDto: CreateTrainingDto) { @ApiResponse({ status: 200, description: 'Return the training record.' })
const data = await this.trainingService.create(createTrainingDto); @ApiResponse({ status: 404, description: 'Training record not found.' })
return { message: 'Training record created successfully', data }; async findOne(@Param('id') id: string) {
} const data = await this.trainingService.findOne(+id);
return { message: 'Training record fetched successfully', data };
}
@Patch(':id') // create training record
@ApiOperation({ summary: 'Update a training record' }) @Post()
@ApiResponse({ status: 200, description: 'Training record updated successfully.' }) @UseInterceptors(FilesInterceptor('files', 3))
@ApiResponse({ status: 404, description: 'Training record not found.' }) @ApiConsumes('multipart/form-data')
async update(@Param('id') id: string, @Body() updateTrainingDto: UpdateTrainingDto) { @ApiOperation({ summary: 'Create a new training record' })
const data = await this.trainingService.update(+id, updateTrainingDto); @ApiResponse({
return { message: 'Training record updated successfully', data }; status: 201,
} description: 'Training record created successfully.',
})
async create(
@Body() createTrainingDto: CreateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) {
const data = await this.trainingService.create(createTrainingDto, files);
return { message: 'Training record created successfully', data };
}
@Delete(':id') // update training record
@ApiOperation({ summary: 'Delete a training record' }) @Patch(':id')
@ApiResponse({ status: 200, description: 'Training record deleted successfully.' }) @UseInterceptors(FilesInterceptor('files', 3))
@ApiResponse({ status: 404, description: 'Training record not found.' }) @ApiConsumes('multipart/form-data')
async remove(@Param('id') id: string) { @ApiOperation({ summary: 'Update a training record' })
return await this.trainingService.remove(+id); @ApiResponse({
} status: 200,
description: 'Training record updated successfully.',
})
@ApiResponse({ status: 404, description: 'Training record not found.' })
async update(
@Param('id') id: string,
@Body() updateTrainingDto: UpdateTrainingDto,
@UploadedFiles(ImageProcessingPipe) files: Express.Multer.File[],
) {
const data = await this.trainingService.update(
+id,
updateTrainingDto,
files,
);
return { message: 'Training record updated successfully', data };
}
// delete training record
@Delete(':id')
@ApiOperation({ summary: 'Delete a training record' })
@ApiResponse({
status: 200,
description: 'Training record deleted successfully.',
})
@ApiResponse({ status: 404, description: 'Training record not found.' })
async remove(@Param('id') id: string) {
return await this.trainingService.remove(+id);
}
} }

View File

@@ -1,223 +1,539 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; import { HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { and, eq, getTableColumns, 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 * as schema from 'src/database/index'; import * as schema from 'src/database/index';
import { trainingSurveys } from 'src/database/index'; import { municipalities, parishes, states, trainingSurveys } from 'src/database/index';
import { eq, like, or, and, gte, lte, SQL, sql } from 'drizzle-orm'; // import XlsxPopulate from 'xlsx-populate';
import { CreateTrainingDto } from './dto/create-training.dto'; import ExcelJS from 'exceljs';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { states } from 'src/database/index';
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import sharp from 'sharp';
@Injectable() @Injectable()
export class TrainingService { export class TrainingService {
constructor( constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>, @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { } ) { }
async findAll(paginationDto?: PaginationDto) {
const {
page = 1,
limit = 10,
search = '',
sortBy = 'id',
sortOrder = 'asc',
} = paginationDto || {};
async findAll(paginationDto?: PaginationDto) { const offset = (page - 1) * limit;
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
const offset = (page - 1) * limit; let searchCondition: SQL<unknown> | undefined;
if (search) {
let searchCondition: SQL<unknown> | undefined; searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
if (search) {
searchCondition = or(
like(trainingSurveys.firstname, `%${search}%`),
like(trainingSurveys.lastname, `%${search}%`),
like(trainingSurveys.ospName, `%${search}%`),
like(trainingSurveys.ospRif, `%${search}%`)
);
}
const orderBy = sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
const data = await this.drizzle
.select()
.from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
} }
async getStatistics(filterDto: TrainingStatisticsFilterDto) { const orderBy =
const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto; sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const filters: SQL[] = []; const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
if (startDate) { const totalCount = Number(totalCountResult[0].count);
filters.push(gte(trainingSurveys.visitDate, new Date(startDate))); const totalPages = Math.ceil(totalCount / limit);
}
if (endDate) { const data = await this.drizzle
filters.push(lte(trainingSurveys.visitDate, new Date(endDate))); .select()
} .from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
if (stateId) { const meta = {
filters.push(eq(trainingSurveys.state, stateId)); page,
} limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
if (municipalityId) { return { data, meta };
filters.push(eq(trainingSurveys.municipality, municipalityId)); }
}
if (parishId) { async getStatistics(filterDto: TrainingStatisticsFilterDto) {
filters.push(eq(trainingSurveys.parish, parishId)); const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
} filterDto;
if (ospType) { const filters: SQL[] = [];
filters.push(eq(trainingSurveys.ospType, ospType));
}
const whereCondition = filters.length > 0 ? and(...filters) : undefined; if (startDate)
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
if (endDate)
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
if (stateId) filters.push(eq(trainingSurveys.state, stateId));
if (municipalityId)
filters.push(eq(trainingSurveys.municipality, municipalityId));
if (parishId) filters.push(eq(trainingSurveys.parish, parishId));
if (ospType) filters.push(eq(trainingSurveys.ospType, ospType));
const totalOspsResult = await this.drizzle const whereCondition = filters.length > 0 ? and(...filters) : undefined;
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition);
const totalOsps = Number(totalOspsResult[0].count);
const totalProducersResult = await this.drizzle // Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad
.select({ sum: sql<number>`sum(${trainingSurveys.producerCount})` }) const [
.from(trainingSurveys) totalOspsResult,
.where(whereCondition); totalProducersResult,
const totalProducers = Number(totalProducersResult[0].sum || 0); totalProductsResult, // Nuevo: Calculado desde el JSON
statusDistribution,
activityDistribution,
typeDistribution,
stateDistribution,
yearDistribution,
] = await Promise.all([
// 1. Total OSPs
this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition),
const statusDistribution = await this.drizzle // 2. Total Productores (Columna plana que mantuviste)
.select({ this.drizzle
name: trainingSurveys.currentStatus, .select({
value: sql<number>`count(*)` sum: sql<number>`
}) SUM(
.from(trainingSurveys) (
.where(whereCondition) SELECT SUM(
.groupBy(trainingSurveys.currentStatus); COALESCE((item->>'menCount')::int, 0) +
COALESCE((item->>'womenCount')::int, 0)
)
FROM jsonb_array_elements(${trainingSurveys.productList}) as item
)
)
`,
})
.from(trainingSurveys)
.where(whereCondition),
const activityDistribution = await this.drizzle // 3. NUEVO: Total Productos (Contamos el largo del array JSON productList)
.select({ this.drizzle
name: trainingSurveys.productiveActivity, .select({
value: sql<number>`count(*)` sum: sql<number>`sum(jsonb_array_length(${trainingSurveys.productList}))`,
}) })
.from(trainingSurveys) .from(trainingSurveys)
.where(whereCondition) .where(whereCondition),
.groupBy(trainingSurveys.productiveActivity);
const typeDistribution = await this.drizzle // 4. Distribución por Estatus
.select({ this.drizzle
name: trainingSurveys.ospType, .select({
value: sql<number>`count(*)` name: trainingSurveys.currentStatus,
}) value: sql<number>`count(*)`,
.from(trainingSurveys) })
.where(whereCondition) .from(trainingSurveys)
.groupBy(trainingSurveys.ospType); .where(whereCondition)
.groupBy(trainingSurveys.currentStatus),
// New Aggregations // 5. Distribución por Actividad
const stateDistribution = await this.drizzle this.drizzle
.select({ .select({
name: states.name, name: trainingSurveys.productiveActivity,
value: sql<number>`count(${trainingSurveys.id})` value: sql<number>`count(*)`,
}) })
.from(trainingSurveys) .from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id)) .where(whereCondition)
.where(whereCondition) .groupBy(trainingSurveys.productiveActivity),
.groupBy(states.name);
const yearDistribution = await this.drizzle // 6. Distribución por Tipo
.select({ this.drizzle
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`, .select({
value: sql<number>`count(*)` name: trainingSurveys.ospType,
}) value: sql<number>`count(*)`,
.from(trainingSurveys) })
.where(whereCondition) .from(trainingSurveys)
.groupBy(trainingSurveys.companyConstitutionYear) .where(whereCondition)
.orderBy(trainingSurveys.companyConstitutionYear); .groupBy(trainingSurveys.ospType),
return { // 7. Distribución por Estado (CORREGIDO con COALESCE)
totalOsps, this.drizzle
totalProducers, .select({
statusDistribution: statusDistribution.map(item => ({ ...item, value: Number(item.value) })), // Si states.name es NULL, devuelve 'Sin Asignar'
activityDistribution: activityDistribution.map(item => ({ ...item, value: Number(item.value) })), name: sql<string>`COALESCE(${states.name}, 'Sin Asignar')`,
typeDistribution: typeDistribution.map(item => ({ ...item, value: Number(item.value) })), value: sql<number>`count(${trainingSurveys.id})`,
stateDistribution: stateDistribution.map(item => ({ ...item, value: Number(item.value) })), })
yearDistribution: yearDistribution.map(item => ({ ...item, value: Number(item.value) })), .from(trainingSurveys)
}; .leftJoin(states, eq(trainingSurveys.state, states.id))
.where(whereCondition)
// Importante: Agrupar también por el resultado del COALESCE o por states.name
.groupBy(states.name),
// 8. Distribución por Año
this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear),
]);
return {
totalOsps: Number(totalOspsResult[0]?.count || 0),
totalProducers: Number(totalProducersResult[0]?.sum || 0),
totalProducts: Number(totalProductsResult[0]?.sum || 0), // Dato extraído del JSON
statusDistribution: statusDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
activityDistribution: activityDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
typeDistribution: typeDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
stateDistribution: stateDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
yearDistribution: yearDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
};
}
async findOne(id: number) {
const find = await this.drizzle
.select({
...getTableColumns(trainingSurveys),
stateName: states.name,
municipalityName: municipalities.name,
parishName: parishes.name,
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.leftJoin(municipalities, eq(trainingSurveys.municipality, municipalities.id))
.leftJoin(parishes, eq(trainingSurveys.parish, parishes.id))
.where(eq(trainingSurveys.id, id))
if (find.length === 0) {
throw new HttpException(
'Training record not found',
HttpStatus.NOT_FOUND,
);
} }
async findOne(id: number) { return find[0];
const find = await this.drizzle }
.select()
.from(trainingSurveys)
.where(eq(trainingSurveys.id, id));
if (find.length === 0) { private async saveFiles(files: Express.Multer.File[]): Promise<string[]> {
throw new HttpException('Training record not found', HttpStatus.NOT_FOUND); if (!files || files.length === 0) return [];
const uploadDir = './uploads/training';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const savedPaths: string[] = [];
for (const file of files) {
const fileName = `${Date.now()}-${Math.round(Math.random() * 1e9)}.png`;
const filePath = path.join(uploadDir, fileName);
// Convertir a PNG usando sharp antes de guardar
await sharp(file.buffer)
.png()
.toFile(filePath);
savedPaths.push(`/assets/training/${fileName}`);
}
return savedPaths;
}
private deleteFile(assetPath: string) {
if (!assetPath) 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 {
fs.unlinkSync(fullPath);
} catch (err) {
console.error(`Error deleting file ${fullPath}:`, err);
}
}
}
async create(
createTrainingDto: CreateTrainingDto,
files: Express.Multer.File[],
) {
// 1. Guardar fotos
const photoPaths = await this.saveFiles(files);
// 2. Extraer solo visitDate para formatearlo.
const { visitDate, state, municipality, parish, ...rest } = createTrainingDto;
const [newRecord] = await this.drizzle
.insert(trainingSurveys)
.values({
// Insertamos el resto de datos planos y las listas (arrays)
...rest,
// Conversión de fecha
visitDate: new Date(visitDate),
// 3. Asignar fotos de forma segura
photo1: photoPaths[0] ?? null,
photo2: photoPaths[1] ?? null,
photo3: photoPaths[2] ?? null,
state: Number(state) ?? null,
municipality: Number(municipality) ?? null,
parish: Number(parish) ?? null,
})
.returning();
return newRecord;
}
async update(
id: number,
updateTrainingDto: UpdateTrainingDto,
files: Express.Multer.File[],
) {
const currentRecord = await this.findOne(id);
const photoPaths = await this.saveFiles(files);
const updateData: any = { ...updateTrainingDto };
// Handle photo updates/removals
const photoFields = ['photo1', 'photo2', 'photo3'] as const;
// 1. If we have NEW files, they replace any old files or occupy empty slots
if (photoPaths.length > 0) {
photoPaths.forEach((newPath, idx) => {
const fieldName = photoFields[idx];
const oldPath = currentRecord[fieldName];
if (oldPath && oldPath !== newPath) {
this.deleteFile(oldPath);
} }
updateData[fieldName] = newPath;
return find[0]; });
} }
async create(createTrainingDto: CreateTrainingDto) { // 2. If the user explicitly cleared a photo field (updateData.photoX === '')
const [newRecord] = await this.drizzle photoFields.forEach((field) => {
.insert(trainingSurveys) if (updateData[field] === '') {
.values({ const oldPath = currentRecord[field];
...createTrainingDto, if (oldPath) this.deleteFile(oldPath);
visitDate: new Date(createTrainingDto.visitDate), updateData[field] = null; // Set to null in DB
}) }
.returning(); });
return newRecord; if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(updateTrainingDto.visitDate);
} }
async update(id: number, updateTrainingDto: UpdateTrainingDto) { const [updatedRecord] = await this.drizzle
await this.findOne(id); .update(trainingSurveys)
.set(updateData)
.where(eq(trainingSurveys.id, id))
.returning();
const updateData: any = { ...updateTrainingDto }; return updatedRecord;
if (updateTrainingDto.visitDate) { }
updateData.visitDate = new Date(updateTrainingDto.visitDate);
}
const [updatedRecord] = await this.drizzle async remove(id: number) {
.update(trainingSurveys) const record = await this.findOne(id);
.set(updateData)
.where(eq(trainingSurveys.id, id))
.returning();
return updatedRecord; // Delete associated files
if (record.photo1) this.deleteFile(record.photo1);
if (record.photo2) this.deleteFile(record.photo2);
if (record.photo3) this.deleteFile(record.photo3);
const [deletedRecord] = await this.drizzle
.delete(trainingSurveys)
.where(eq(trainingSurveys.id, id))
.returning();
return {
message: 'Training record deleted successfully',
data: deletedRecord,
};
}
async exportTemplate(id: number) {
// Validar que el registro exista
const record = await this.findOne(id);
if (!record) throw new NotFoundException(`No se encontró el registro`);
// Formatear fecha y hora
const dateObj = new Date(record.visitDate);
const fechaFormateada = dateObj.toLocaleDateString('es-ES');
const horaFormateada = dateObj.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
});
// Ruta de la plantilla
const templatePath = path.join(
__dirname,
'export_template',
'excel.osp.xlsx',
);
// Cargar la plantilla con ExcelJS
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(templatePath);
const worksheet = workbook.getWorksheet(1); // Usar la primera hoja
if (!worksheet) {
throw new Error('No se pudo encontrar la hoja de trabajo en la plantilla');
} }
async remove(id: number) { // Llenar los datos principales
await this.findOne(id); worksheet.getCell('A6').value = record.productiveSector;
worksheet.getCell('B8').value = record.stateName;
worksheet.getCell('E8').value = record.municipalityName;
worksheet.getCell('B9').value = record.parishName;
worksheet.getCell('D6').value = record.ospName;
worksheet.getCell('L5').value = fechaFormateada;
worksheet.getCell('L6').value = horaFormateada;
worksheet.getCell('B10').value = record.ospAddress;
worksheet.getCell('C11').value = record.communeEmail;
worksheet.getCell('C12').value = record.communeSpokespersonName;
worksheet.getCell('G11').value = record.communeRif;
worksheet.getCell('G12').value = record.communeSpokespersonPhone;
worksheet.getCell('C13').value = record.siturCodeCommune;
worksheet.getCell('G13').value = record.siturCodeCommunalCouncil;
worksheet.getCell('G14').value = record.communalCouncilRif;
worksheet.getCell('C15').value = record.communalCouncilSpokespersonName;
worksheet.getCell('G15').value = record.communalCouncilSpokespersonPhone;
worksheet.getCell('C16').value = record.ospType;
worksheet.getCell('C17').value = record.ospName;
worksheet.getCell('C18').value = record.productiveActivity;
worksheet.getCell('C19').value = 'Proveedores';
worksheet.getCell('C20').value = record.companyConstitutionYear;
worksheet.getCell('C21').value = record.infrastructureMt2;
worksheet.getCell('G17').value = record.ospRif;
const [deletedRecord] = await this.drizzle worksheet.getCell(record.hasTransport === true ? 'J19' : 'L19').value = 'X';
.delete(trainingSurveys) worksheet.getCell(record.structureType === 'CASA' ? 'J20' : 'L20').value =
.where(eq(trainingSurveys.id, id)) 'X';
.returning(); worksheet.getCell(record.isOpenSpace === true ? 'J21' : 'L21').value = 'X';
return { message: 'Training record deleted successfully', data: deletedRecord }; worksheet.getCell('A24').value = record.ospResponsibleFullname;
worksheet.getCell('C24').value = record.ospResponsibleCedula;
worksheet.getCell('E24').value = record.ospResponsiblePhone;
worksheet.getCell('J24').value = 'N Femenino'; // Placeholder si no hay dato
worksheet.getCell('L24').value = 'N Masculino'; // Placeholder si no hay dato
// const photo1 = record.photo1;
// const photo2 = record.photo2;
// const photo3 = record.photo3;
if (record.photo1) {
const image = record.photo1.slice(17);
const extension = image.split('.')[1];
// Validar que sea una imagen png, gif o jpeg
if (extension === 'png' || extension === 'gif' || extension === 'jpeg') {
// Ruta de la imagen
const imagePath = path.join(
__dirname,
'../../../',
`uploads/training/${image}`,
);
// Add an image to the workbook from a file buffer
const logoId = workbook.addImage({
filename: imagePath,
extension: extension,
});
// Anchor the image to a specific cell (e.g., A1)
worksheet.addImage(logoId, `I7:L17`); // Spans from A1 to C3
}
} }
// let i = 1;
// while (i <= 3) {
// const element = record[`photo${i}`];
// if (element) {
// const image = element.slice(17);
// const extension: extensionType = image.split('.')[1];
// // Validar que sea una imagen png, gif o jpeg
// if (extension === 'png' || extension === 'gif' || extension === 'jpeg') {
// // Ruta de la imagen
// const imagePath = path.join(
// __dirname,
// '../../../',
// `uploads/training/${image}`,
// );
// // Add an image to the workbook from a file buffer
// const logoId = workbook.addImage({
// filename: imagePath,
// extension: extension,
// });
// // Anchor the image to a specific cell (e.g., A1)
// worksheet.addImage(logoId, `I7:L17`); // Spans from A1 to C3
// i = 4;
// }
// }
// i++;
// }
// Listas (Equipos, Materia Prima, Productos)
const equipmentList = Array.isArray(record.equipmentList)
? record.equipmentList
: [];
const productionList = Array.isArray(record.productionList)
? record.productionList
: [];
const productList = Array.isArray(record.productList)
? record.productList
: [];
// Colocar listas empezando en la fila 28
equipmentList.forEach((item: any, i: number) => {
const row = 28 + i;
worksheet.getCell(`A${row}`).value = item.machine;
worksheet.getCell(`C${row}`).value = item.quantity;
});
productionList.forEach((item: any, i: number) => {
const row = 28 + i;
worksheet.getCell(`E${row}`).value = item.rawMaterial;
worksheet.getCell(`G${row}`).value = item.quantity;
});
productList.forEach((item: any, i: number) => {
const row = 28 + i;
worksheet.getCell(`I${row}`).value = item.productName;
worksheet.getCell(`J${row}`).value = item.dailyCount;
worksheet.getCell(`K${row}`).value = item.weeklyCount;
worksheet.getCell(`L${row}`).value = item.monthlyCount;
});
return await workbook.xlsx.writeBuffer();
}
} }

View File

@@ -1,4 +1,5 @@
AUTH_URL = http://localhost:3000 AUTH_URL = http://localhost:3000
AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE= AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE=
API_URL=http://localhost:8000 API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8000

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export default async function Dashboard() { export default async function Dashboard() {
console.log('La sesion es llamada');
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {

View File

@@ -44,6 +44,7 @@ const RootLayout = async ({
}: Readonly<{ }: Readonly<{
children: ReactNode; children: ReactNode;
}>) => { }>) => {
console.log('La sesion es llamada');
const session = await auth(); const session = await auth();
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>

View File

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

View File

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

View File

@@ -10,32 +10,23 @@ export const GeneralItems: NavItem[] = [
isActive: false, isActive: false,
items: [], // No child items items: [], // No child items
}, },
{ // {
title: 'ProduTienda', // title: 'ProduTienda',
url: '/dashboard/productos/', // url: '/dashboard/productos/',
icon: 'blocks', // icon: 'blocks',
shortcut: ['p', 'p'], // shortcut: ['p', 'p'],
isActive: false, // isActive: false,
items: [], // No child items // items: [], // No child items
}, // },
{
title: 'Formulario',
url: '/dashboard/formulario/',
icon: 'notepadText',
shortcut: ['p', 'p'],
isActive: false,
items: [], // No child items
},
]; ];
export const AdministrationItems: NavItem[] = [ export const AdministrationItems: NavItem[] = [
{ {
title: 'Administracion', title: 'Administracion',
url: '#', // Placeholder as there is no direct link for the parent url: '#', // Placeholder as there is no direct link for the parent
icon: 'settings2', icon: 'settings2',
isActive: true, isActive: true,
role: ['admin', 'superadmin', 'manager', 'user'], // sumatoria de los roles que si tienen acceso role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'], // sumatoria de los roles que si tienen acceso
items: [ items: [
{ {
@@ -43,14 +34,21 @@ export const AdministrationItems: NavItem[] = [
url: '/dashboard/administracion/usuario', url: '/dashboard/administracion/usuario',
icon: 'userPen', icon: 'userPen',
shortcut: ['m', 'm'], shortcut: ['m', 'm'],
role: ['admin', 'superadmin'], role: ['admin', 'superadmin', 'autoridad'],
}, },
{ {
title: 'Encuestas', title: 'Encuestas',
shortcut: ['l', 'l'], shortcut: ['l', 'l'],
url: '/dashboard/administracion/encuestas', url: '/dashboard/administracion/encuestas',
icon: 'login', icon: 'login',
role: ['admin', 'superadmin', 'manager', 'user'], role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'Registro OSP',
shortcut: ['p', 'p'],
url: '/dashboard/formulario/',
icon: 'notepadText',
role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'],
}, },
], ],
}, },
@@ -62,7 +60,7 @@ export const StatisticsItems: NavItem[] = [
url: '#', // Placeholder as there is no direct link for the parent url: '#', // Placeholder as there is no direct link for the parent
icon: 'chartColumn', icon: 'chartColumn',
isActive: true, isActive: true,
role: ['admin', 'superadmin', 'autoridad'], role: ['admin', 'superadmin', 'autoridad', 'manager'],
items: [ items: [
// { // {
@@ -80,17 +78,12 @@ export const StatisticsItems: NavItem[] = [
role: ['admin', 'superadmin', 'autoridad'], role: ['admin', 'superadmin', 'autoridad'],
}, },
{ {
title: 'Socioproductiva', title: 'Datos OSP',
shortcut: ['s', 's'], shortcut: ['s', 's'],
url: '/dashboard/estadisticas/socioproductiva', url: '/dashboard/estadisticas/socioproductiva',
icon: 'blocks', icon: 'blocks',
role: ['admin', 'superadmin', 'autoridad'], role: ['admin', 'superadmin', 'autoridad', 'manager'],
}, },
], ],
}, },
]; ];

View File

@@ -1,5 +1,5 @@
'use server'; 'use server';
import { safeFetchApi } from '@/lib'; import { safeFetchApi } from '@/lib/fetch.api';
import { loginResponseSchema, UserFormValue } from '../schemas/login'; import { loginResponseSchema, UserFormValue } from '../schemas/login';
type LoginActionSuccess = { type LoginActionSuccess = {
@@ -20,9 +20,9 @@ type LoginActionSuccess = {
} }
type LoginActionError = { type LoginActionError = {
type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí** type: 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR'; // **Asegúrate de que el tipo de `type` sea este aquí**
message: string; message: string;
details?: any; details?: any;
}; };
// Si SignInAction también puede devolver null, asegúralo en su tipo de retorno // Si SignInAction también puede devolver null, asegúralo en su tipo de retorno
@@ -37,7 +37,7 @@ export const SignInAction = async (payload: UserFormValue): Promise<LoginActionR
); );
if (error) { if (error) {
return { return {
type: error.type as 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR', type: error.type as 'API_ERROR' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR',
message: error.message, message: error.message,
details: error.details details: error.details
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,504 @@
import { COUNTRY_OPTIONS } from '@/constants/countries';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
import { Button } from '@repo/shadcn/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@repo/shadcn/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { SelectSearchable } from '@repo/shadcn/select-searchable';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
// 1. Definimos la estructura de los datos para que TypeScript no se queje
interface ProductItem {
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() {
// 2. Pasamos el tipo genérico a useFormContext
const { control, register } = useFormContext<ProductFormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'productList',
});
const [isOpen, setIsOpen] = useState(false);
// Modal Form State
const [newItem, setNewItem] = useState<any>({
productName: '',
description: '',
dailyCount: '',
weeklyCount: '',
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 = () => {
if (newItem.productName) {
append(newItem);
setNewItem({
productName: '',
description: '',
dailyCount: '',
weeklyCount: '',
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);
}
};
const stateOptions = statesData?.data || [];
const internalMuniOptions = internalMuniData?.data || [];
const internalParishOptions = internalParishData?.data || [];
const externalMuniOptions = externalMuniData?.data || [];
const externalParishOptions = externalParishData?.data || [];
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Datos de Actividad Productiva</h3>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">Agregar Producto/Actividad</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Detalles de Actividad Productiva</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Datos de actividad productiva
</DialogDescription>
<div className="space-y-6 py-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Producto Terminado</Label>
<Input
value={newItem.productName}
onChange={(e) =>
setNewItem({ ...newItem, productName: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Descripción</Label>
<Input
value={newItem.description}
onChange={(e) =>
setNewItem({ ...newItem, description: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Cant. Diario</Label>
<Input
type="number"
value={newItem.dailyCount}
onChange={(e) =>
setNewItem({ ...newItem, dailyCount: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Cant. Semanal</Label>
<Input
type="number"
value={newItem.weeklyCount}
onChange={(e) =>
setNewItem({ ...newItem, weeklyCount: e.target.value })
}
/>
</div>
<div className="space-y-2">
<Label>Cant. Mensual</Label>
<Input
type="number"
value={newItem.monthlyCount}
onChange={(e) =>
setNewItem({ ...newItem, monthlyCount: e.target.value })
}
/>
</div>
</div>
<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">
<Button
variant="outline"
type="button"
onClick={() => setIsOpen(false)}
>
Cancelar
</Button>
<Button onClick={handleAdd}>Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Producto</TableHead>
<TableHead>Descripción</TableHead>
<TableHead>Mensual</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`productList.${index}.productName`)}
// field.productName ahora es válido gracias a la interface
value={field.productName}
/>
{field.productName}
</TableCell>
<TableCell>{field.description}</TableCell>
<TableCell>{field.monthlyCount}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{fields.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
No hay productos registrados
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { Button } from '@repo/shadcn/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@repo/shadcn/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
export function ProductionList() {
const { control, register } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name: 'productionList',
});
const [isOpen, setIsOpen] = useState(false);
const [newItem, setNewItem] = useState({
rawMaterial: '',
supplyType: '',
quantity: '',
});
const handleAdd = () => {
if (newItem.rawMaterial && newItem.quantity) {
append({ ...newItem, quantity: Number(newItem.quantity) });
setNewItem({ rawMaterial: '', supplyType: '', quantity: '' });
setIsOpen(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Datos de Producción</h3>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">Agregar Producción</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Agregar Datos de Producción</DialogTitle>
</DialogHeader>
<DialogDescription className="sr-only">
Datos de producción
</DialogDescription>
<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">
<Label>Tipo de Insumo/Rubro</Label>
<Input
value={newItem.supplyType}
onChange={(e) =>
setNewItem({ ...newItem, supplyType: e.target.value })
}
placeholder="Tipo"
/>
</div>
<div className="space-y-2">
<Label>Cantidad Mensual (Kg, TON, UNID. LT)</Label>
<Input
type="number"
value={newItem.quantity}
onChange={(e) =>
setNewItem({ ...newItem, quantity: e.target.value })
}
placeholder="0"
/>
</div>
<div className="flex justify-end gap-4">
<Button
variant="outline"
type="button"
onClick={() => setIsOpen(false)}
>
Cancelar
</Button>
<Button onClick={handleAdd}>Guardar</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Materia Prima</TableHead>
<TableHead>Tipo Insumo</TableHead>
<TableHead>Cantidad (Mensual)</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.rawMaterial`)}
/>
{/* @ts-ignore */}
{field.rawMaterial}
</TableCell>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.supplyType`)}
/>
{/* @ts-ignore */}
{field.supplyType}
</TableCell>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.quantity`)}
/>
{/* @ts-ignore */}
{field.quantity}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{fields.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
No hay datos de producción registrados
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { useDeleteTraining } from '@/feactures/training/hooks/use-training';
import { TrainingSchema } from '@/feactures/training/schemas/training';
import { Button } from '@repo/shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn/tooltip';
import { Edit, Eye, Trash, FileDown } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { TrainingViewModal } from '../training-view-modal';
interface CellActionProps {
data: TrainingSchema;
apiUrl: string;
}
export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const { mutate: deleteTraining } = useDeleteTraining();
const router = useRouter();
const onConfirm = async () => {
try {
setLoading(true);
deleteTraining(data.id!);
setOpen(false);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const handleExport = (id?: number | undefined) => {
window.open(`${apiUrl}/training/export/${id}`, '_blank');
};
return (
<>
<AlertModal
isOpen={open}
onClose={() => setOpen(false)}
onConfirm={onConfirm}
loading={loading}
title="¿Estás seguro que desea eliminar este registro?"
description="Esta acción no se puede deshacer."
/>
<TrainingViewModal
isOpen={viewOpen}
onClose={() => setViewOpen(false)}
data={data}
/>
<div className="flex gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setViewOpen(true)}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ver detalle</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<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>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
router.push(`/dashboard/formulario/editar/${data.id}`)
}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Editar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(true)}
>
<Trash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Eliminar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,61 +1,186 @@
import { z } from 'zod'; import { z } from 'zod';
// 1. Definimos el esquema de un item individual de la lista de productos
// Basado en los campos que usaste en ProductActivityList
const productItemSchema = z.object({
productName: z.string(),
description: z.string().optional(),
dailyCount: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs
weeklyCount: z.coerce.string().or(z.number()).optional(),
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({
rawMaterial: z.string(),
supplyType: z.string().optional(),
quantity: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs
});
const equipmentItemSchema = z.object({
machine: z.string(),
specifications: z.string().optional(),
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({
id: z.number().optional(), //Datos de la visita
firstname: z.string().min(1, { message: "Nombre es requerido" }), id: z.number().optional(),
lastname: z.string().min(1, { message: "Apellido es requerido" }), firstname: z.string().min(1, { message: 'Nombre es requerido' }),
visitDate: z.string().or(z.date()).transform((val) => new Date(val).toISOString()), lastname: z.string().min(1, { message: 'Apellido es requerido' }),
productiveActivity: z.string().min(1, { message: "Actividad productiva es requerida" }), coorPhone: z.string().optional().nullable(),
financialRequirementDescription: z.string().min(1, { message: "Descripción es requerida" }), visitDate: z
siturCodeCommune: z.string().min(1, { message: "Código SITUR Comuna es requerido" }), .string()
communalCouncil: z.string().min(1, { message: "Consejo Comunal es requerido" }), .min(1, { message: 'Fecha y hora de visita es requerida' }),
siturCodeCommunalCouncil: z.string().min(1, { message: "Código SITUR Consejo Comunal es requerido" }),
ospName: z.string().min(1, { message: "Nombre de la OSP es requerido" }), //Datos de la organización socioproductiva (OSP)
ospAddress: z.string().min(1, { message: "Dirección de la OSP es requerida" }), ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
ospRif: z.string().min(1, { message: "RIF de la OSP es requerido" }), ecoSector: z.string().optional().or(z.literal('')),
ospType: z.string().min(1, { message: "Tipo de OSP es requerido" }), productiveSector: z.string().optional().or(z.literal('')),
currentStatus: z.string().min(1, { message: "Estatus actual es requerido" }), centralProductiveActivity: z.string().optional().or(z.literal('')),
companyConstitutionYear: z.coerce.number().min(1900, { message: "Año inválido" }), mainProductiveActivity: z.string().optional().or(z.literal('')),
producerCount: z.coerce.number().min(1, { message: "Cantidad de productores requerida" }), productiveActivity: z
productDescription: z.string().min(1, { message: "Descripción del producto es requerida" }), .string()
installedCapacity: z.string().min(1, { message: "Capacidad instalada es requerida" }), .min(1, { message: 'Actividad productiva es requerida' }),
operationalCapacity: z.string().min(1, { message: "Capacidad operativa es requerida" }), ospRif: z.string().optional().or(z.literal('')),
ospResponsibleFullname: z.string().min(1, { message: "Nombre del responsable es requerido" }), ospName: z.string().min(1, { message: 'Nombre de la OSP es requerido' }),
ospResponsibleCedula: z.string().min(1, { message: "Cédula del responsable es requerida" }), companyConstitutionYear: z.coerce
ospResponsibleRif: z.string().min(1, { message: "RIF del responsable es requerido" }), .number()
ospResponsiblePhone: z.string().min(1, { message: "Teléfono del responsable es requerido" }), .min(1900, { message: 'Año inválido' }),
civilState: z.string().min(1, { message: "Estado civil es requerido" }), currentStatus: z
familyBurden: z.coerce.number().min(0, { message: "Carga familiar requerida" }), .string()
numberOfChildren: z.coerce.number().min(0, { message: "Número de hijos requerido" }), .min(1, { message: 'Estatus actual es requerido' })
ospResponsibleEmail: z.string().email({ message: "Correo electrónico inválido" }), .default('ACTIVA'),
generalObservations: z.string().optional().default(''), infrastructureMt2: z.string().optional().or(z.literal('')),
photo1: z.string().optional().default(''), hasTransport: z
photo2: z.string().optional().default(''), .preprocess((val) => val === 'true' || val === true, z.boolean())
photo3: z.string().optional().default(''), .optional(),
paralysisReason: z.string().optional().default(''), structureType: z.string().optional().or(z.literal('')),
state: z.number().optional().nullable(), isOpenSpace: z
municipality: z.number().optional().nullable(), .preprocess((val) => val === 'true' || val === true, z.boolean())
parish: z.number().optional().nullable(), .optional(),
paralysisReason: z.string().optional().default(''),
//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([]),
//Detalles de la ubicación
ospAddress: z
.string()
.min(1, { message: 'Dirección de la OSP es requerida' }),
ospGoogleMapsLink: z.string().optional().or(z.literal('')),
communeName: z.string().optional().or(z.literal('')),
siturCodeCommune: z.string().optional().or(z.literal('')),
communeRif: z.string().optional().or(z.literal('')),
communeSpokespersonName: z.string().optional().or(z.literal('')),
communeSpokespersonCedula: z.string().optional().or(z.literal('')),
communeSpokespersonRif: z.string().optional().or(z.literal('')),
communeSpokespersonPhone: z.string().optional().or(z.literal('')),
communeEmail: z
.string()
.email({ message: 'Correo electrónico de la Comuna inválido' })
.optional()
.or(z.literal('')),
communalCouncil: z
.string()
.min(1, { message: 'Consejo Comunal es requerido' }),
siturCodeCommunalCouncil: z.string().optional().or(z.literal('')),
communalCouncilRif: z.string().optional().or(z.literal('')),
communalCouncilSpokespersonName: z.string().optional().or(z.literal('')),
communalCouncilSpokespersonCedula: z.string().optional().or(z.literal('')),
communalCouncilSpokespersonRif: z.string().optional().or(z.literal('')),
communalCouncilSpokespersonPhone: z.string().optional().or(z.literal('')),
communalCouncilEmail: z
.string()
.email({ message: 'Correo electrónico del Consejo Comunal inválido' })
.optional()
.or(z.literal('')),
//Datos del Responsable OSP
ospResponsibleCedula: z
.string()
.min(1, { message: 'Cédula del responsable es requerida' }),
ospResponsibleFullname: z
.string()
.min(1, { message: 'Nombre del responsable es requerido' }),
ospResponsibleRif: z
.string()
.min(1, { message: 'RIF del responsable es requerido' }),
civilState: z.string().min(1, { message: 'Estado civil es requerido' }),
ospResponsiblePhone: z
.string()
.min(1, { message: 'Teléfono del responsable es requerido' }),
ospResponsibleEmail: z
.string()
.email({ message: 'Correo electrónico inválido' }),
familyBurden: z.coerce
.number()
.min(0, { message: 'Carga familiar requerida' }),
numberOfChildren: z.coerce
.number()
.min(0, { message: 'Número de hijos requerido' }),
//Datos adicionales
generalObservations: z.string().optional().default(''),
//IMAGENES
files: z.any().optional(),
//no se envia la backend al crear ni editar el formulario
state: z.number().optional().nullable(),
municipality: z.number().optional().nullable(),
parish: z.number().optional().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(),
}); });
export type TrainingSchema = z.infer<typeof trainingSchema>; export type TrainingSchema = z.infer<typeof trainingSchema>;
export const trainingApiResponseSchema = z.object({ export const trainingApiResponseSchema = z.object({
message: z.string(), message: z.string(),
data: z.array(trainingSchema), data: z.array(trainingSchema),
meta: z.object({ meta: z.object({
page: z.number(), page: z.number(),
limit: z.number(), limit: z.number(),
totalCount: z.number(), totalCount: z.number(),
totalPages: z.number(), totalPages: z.number(),
hasNextPage: z.boolean(), hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(), hasPreviousPage: z.boolean(),
nextPage: z.number().nullable(), nextPage: z.number().nullable(),
previousPage: z.number().nullable(), previousPage: z.number().nullable(),
}), }),
}); });
export const TrainingMutate = z.object({ export const TrainingMutate = z.object({
message: z.string(), message: z.string(),
data: trainingSchema, data: trainingSchema,
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
// 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 { resfreshTokenAction } from '@/feactures/auth/actions/refresh-token-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 } from 'next-auth/jwt';
import CredentialProvider from 'next-auth/providers/credentials'; import CredentialProvider from 'next-auth/providers/credentials';
@@ -92,8 +91,6 @@ const authConfig: NextAuthConfig = {
refresh_token: response?.tokens.refresh_token ?? '', refresh_token: response?.tokens.refresh_token ?? '',
refresh_expire_in: response?.tokens.refresh_expire_in ?? 0, refresh_expire_in: response?.tokens.refresh_expire_in ?? 0,
}; };
}, },
}), }),
], ],
@@ -101,11 +98,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) {
@@ -120,7 +113,6 @@ const authConfig: NextAuthConfig = {
refresh_token: user.refresh_token, refresh_token: user.refresh_token,
refresh_expire_in: user.refresh_expire_in refresh_expire_in: user.refresh_expire_in
} }
// return token;
} }
// 2. Si no es un nuevo login, verificar la expiración del token // 2. Si no es un nuevo login, verificar la expiración del token
@@ -131,46 +123,33 @@ const authConfig: NextAuthConfig = {
return token; // Si no ha expirado, no hacer nada y devolver el token actual 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 // 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)) { if (now > (token.refresh_expire_in as number)) {
// console.log("Refresh token ha expirado. Forzando logout.");
return null; // Forzar el logout al devolver null 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 // 4. Si el token de acceso ha expirado pero el refresh token es válido, renovar
// console.log("Renovando token de acceso..."); console.log("Renovando token de acceso...");
try { try {
const refresh_token = { token: token.refresh_token as string, user_id: Number(token.id) as number} const refresh_token = { token: token.refresh_token as string, user_id: Number(token.id) as number }
console.log(refresh_token);
const res = await resfreshTokenAction(refresh_token); const res = await resfreshTokenAction(refresh_token);
if (!res || !res.tokens) { if (!res || !res.tokens) {
throw new Error('Fallo en la respuesta de la API de refresco.'); throw new Error('Fallo en la respuesta de la API de refresco.');
} }
// console.log("Old Access Expire:", token.access_expire_in);
// console.log("New Access Expire:", res.tokens.access_expire_in);
// console.log("token:", token.refresh_token);
// Actualizar el token directamente con los nuevos valores // Actualizar el token directamente con los nuevos valores
token.access_token = res.tokens.access_token; token.access_token = res.tokens.access_token;
token.access_expire_in = res.tokens.access_expire_in; token.access_expire_in = res.tokens.access_expire_in;
token.refresh_token = res.tokens.refresh_token; token.refresh_token = res.tokens.refresh_token;
token.refresh_expire_in = res.tokens.refresh_expire_in; token.refresh_expire_in = res.tokens.refresh_expire_in;
console.log("Token renovado exitosamente.");
return token; return token;
} catch (error) { } catch (error) {
console.error("Error al renovar el token: ", error); console.error(error);
return null; // Fallo al renovar, forzar logout return null; // Fallo al renovar, forzar logout
} }
}, },
@@ -186,6 +165,7 @@ const authConfig: NextAuthConfig = {
email: token.email as string, email: token.email as string,
role: token.role as Array<{ id: number; rol: string }>, role: token.role as Array<{ id: number; rol: string }>,
}; };
console.log("Session: Habilitado");
return session; return session;
}, },
}, },

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

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