29 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
24bc0476e6 form guarda y estadisticas 2025-12-09 17:56:48 -04:00
01c7bd149d Campos faltantes 2025-12-04 19:02:02 -04:00
d3b3fa5e85 select ubicacion 2025-12-02 15:19:57 -04:00
efa1726223 formulario de capacitacion 2025-12-01 18:23:18 -04:00
28d51a9c00 correccion en un and con mala sintaxi en la sesiones 2025-10-09 12:01:13 -04:00
c1d4a40244 refresh token esta vez si (espero) 2025-10-09 11:25:46 -04:00
6f8a55b8fd refresh token arreglado 2025-10-06 10:31:20 -04:00
e2105ccbf5 cambios en el refresh token 2025-10-01 15:13:57 -04:00
d71c25f0ff Merge branch 'inventory' 2025-09-23 10:44:54 -04:00
08fa179276 ajustes responsive login y btn añadir de administracion 2025-07-02 10:10:33 -04:00
5cd663a653 cambio en la descripcion 2025-06-20 12:59:22 -04:00
5137c07c88 Se cambio caja de ahorro por fodemi 2025-06-20 12:58:09 -04:00
93 changed files with 24393 additions and 425 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

@@ -13,13 +13,13 @@ import { ThrottlerGuard } from '@nestjs/throttler';
import { DrizzleModule } from './database/drizzle.module'; import { DrizzleModule } from './database/drizzle.module';
import { AuthModule } from './features/auth/auth.module'; import { AuthModule } from './features/auth/auth.module';
import { ConfigurationsModule } from './features/configurations/configurations.module'; import { ConfigurationsModule } from './features/configurations/configurations.module';
import { LocationModule} from './features/location/location.module' import { LocationModule } from './features/location/location.module'
import { MailModule } from './features/mail/mail.module'; import { MailModule } from './features/mail/mail.module';
import { RolesModule } from './features/roles/roles.module'; import { RolesModule } from './features/roles/roles.module';
import { UserRolesModule } from './features/user-roles/user-roles.module'; import { UserRolesModule } from './features/user-roles/user-roles.module';
import { SurveysModule } from './features/surveys/surveys.module'; import { SurveysModule } from './features/surveys/surveys.module';
import {InventoryModule} from './features/inventory/inventory.module' import { InventoryModule } from './features/inventory/inventory.module';
import { PicturesModule } from './features/pictures/pictures.module'; import { TrainingModule } from './features/training/training.module';
@Module({ @Module({
providers: [ providers: [
@@ -61,7 +61,7 @@ import { PicturesModule } from './features/pictures/pictures.module';
SurveysModule, SurveysModule,
LocationModule, LocationModule,
InventoryModule, InventoryModule,
PicturesModule TrainingModule
], ],
}) })
export class AppModule {} export class AppModule { }

View File

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

View File

@@ -0,0 +1,37 @@
CREATE TABLE "training_surveys" (
"id" serial PRIMARY KEY NOT NULL,
"firstname" text NOT NULL,
"lastname" text NOT NULL,
"visit_date" timestamp NOT NULL,
"productive_activity" text NOT NULL,
"financial_requirement_description" text NOT NULL,
"situr_code_commune" text NOT NULL,
"communal_council" text NOT NULL,
"situr_code_communal_council" text NOT NULL,
"osp_name" text NOT NULL,
"osp_address" text NOT NULL,
"osp_rif" text NOT NULL,
"osp_type" text NOT NULL,
"current_status" text NOT NULL,
"company_constitution_year" integer NOT NULL,
"producer_count" integer NOT NULL,
"product_description" text NOT NULL,
"installed_capacity" text NOT NULL,
"operational_capacity" text NOT NULL,
"osp_responsible_fullname" text NOT NULL,
"osp_responsible_cedula" text NOT NULL,
"osp_responsible_rif" text NOT NULL,
"osp_responsible_phone" text NOT NULL,
"civil_state" text NOT NULL,
"family_burden" integer NOT NULL,
"number_of_children" integer NOT NULL,
"general_observations" text NOT NULL,
"photo1" text NOT NULL,
"photo2" text NOT NULL,
"photo3" text NOT NULL,
"paralysis_reason" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
CREATE INDEX "training_surveys_index_00" ON "training_surveys" USING btree ("firstname");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,8 +1,8 @@
import { sql } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core'; import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps'; import { timestamps } from '../timestamps';
import { users } from './auth'; import { users } from './auth';
import { municipalities, parishes, states } from './general';
// Tabla surveys // Tabla surveys
export const surveys = t.pgTable( export const surveys = t.pgTable(
@@ -18,9 +18,7 @@ export const surveys = t.pgTable(
...timestamps, ...timestamps,
}, },
(surveys) => ({ (surveys) => ({
surveysIndex: t surveysIndex: t.index('surveys_index_00').on(surveys.title),
.index('surveys_index_00')
.on(surveys.title),
}), }),
); );
@@ -44,7 +42,118 @@ export const answersSurveys = t.pgTable(
}), }),
); );
// Tabla training_surveys
export const trainingSurveys = t.pgTable(
'training_surveys',
{
// === 1. IDENTIFICADORES Y DATOS DE VISITA ===
id: t.serial('id').primaryKey(),
firstname: t.text('firstname').notNull(),
lastname: t.text('lastname').notNull(),
visitDate: t.timestamp('visit_date').notNull(),
coorPhone: t.text('coor_phone'),
// === 2. UBICACIÓN (Claves Foráneas - Nullables) ===
state: t
.integer('state')
.references(() => states.id, { onDelete: 'set null' }),
municipality: t
.integer('municipality')
.references(() => municipalities.id, { onDelete: 'set null' }),
parish: t
.integer('parish')
.references(() => parishes.id, { onDelete: 'set null' }),
// === 3. DATOS DE LA OSP (Organización Socioproductiva) ===
ospType: t.text('osp_type').notNull(), // UPF, EPS, etc.
ecoSector: t.text('eco_sector').notNull().default(''),
productiveSector: t.text('productive_sector').notNull().default(''),
centralProductiveActivity: t
.text('central_productive_activity')
.notNull()
.default(''),
mainProductiveActivity: t
.text('main_productive_activity')
.notNull()
.default(''),
productiveActivity: t.text('productive_activity').notNull(),
ospRif: t.text('osp_rif').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(),
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(),
siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(),
communalCouncilRif: t.text('communal_council_rif').notNull().default(''),
communalCouncilSpokespersonName: t
.text('communal_council_spokesperson_name')
.notNull()
.default(''),
communalCouncilSpokespersonCedula: t
.text('communal_council_spokesperson_cedula')
.notNull()
.default(''),
communalCouncilSpokespersonRif: t
.text('communal_council_spokesperson_rif')
.notNull()
.default(''),
communalCouncilSpokespersonPhone: t
.text('communal_council_spokesperson_phone')
.notNull()
.default(''),
communalCouncilEmail: t
.text('communal_council_email')
.notNull()
.default(''),
ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(),
ospResponsibleCedula: t.text('osp_responsible_cedula').notNull(),
ospResponsibleRif: t.text('osp_responsible_rif').notNull(),
civilState: t.text('civil_state').notNull(),
ospResponsiblePhone: t.text('osp_responsible_phone').notNull(),
ospResponsibleEmail: t.text('osp_responsible_email').notNull(),
familyBurden: t.integer('family_burden').notNull(),
numberOfChildren: t.integer('number_of_children').notNull(),
generalObservations: t.text('general_observations'),
photo1: t.text('photo1'),
photo2: t.text('photo2'),
photo3: t.text('photo3'),
...timestamps,
},
(trainingSurveys) => ({
trainingSurveysIndex: t
.index('training_surveys_index_00')
.on(trainingSurveys.firstname),
}),
);
export const viewSurveys = t.pgView('v_surveys', { export const viewSurveys = t.pgView('v_surveys', {
surverId: t.integer('survey_id'), surverId: t.integer('survey_id'),
@@ -52,6 +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 })
.as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`); where published = true`);

View File

@@ -1,3 +1,4 @@
// api/src/feacture/auth/auth.controller.ts
import { Public } from '@/common/decorators'; import { Public } from '@/common/decorators';
import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard'; import { JwtRefreshGuard } from '@/common/guards/jwt-refresh.guard';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto'; import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
@@ -51,24 +52,21 @@ export class AuthController {
// return { message: 'Password reset link sent to your email' }; // return { message: 'Password reset link sent to your email' };
// } // }
@UseGuards(JwtRefreshGuard) // @UseGuards(JwtRefreshGuard)
@Public() @Public()
@HttpCode(200) @HttpCode(200)
@Patch('refresh') @Patch('refresh')
//@RequirePermissions('auth:refresh-token') //@RequirePermissions('auth:refresh-token')
async refreshToken(@Req() req: Request,@Body() refreshTokenDto: RefreshTokenDto) { async refreshToken(@Body() refreshTokenDto: any) {
// console.log("Pepeeeee"); console.log('refreshTokenDto', refreshTokenDto);
// console.log(req['user']);
// console.log("refreshTokenDto",refreshTokenDto);
// console.log(typeof refreshTokenDto);
const data = await this.authService.refreshToken(refreshTokenDto,req['user'].sub); const data = await this.authService.refreshToken(refreshTokenDto);
// console.log("data",data);
if (!data) { // console.log('data', data);
return null;
}
if (!data) return null;
return {tokens: data} return {tokens: data}
} }

View File

@@ -1,10 +1,11 @@
// auth.service
import { envs } from '@/common/config/envs'; import { envs } from '@/common/config/envs';
import { Env, validateString } from '@/common/utils'; import { Env, validateString } from '@/common/utils';
import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider'; import { DRIZZLE_PROVIDER } from '@/database/drizzle-provider';
import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto'; import { RefreshTokenDto } from '@/features/auth/dto/refresh-token.dto';
import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto'; import { SignInUserDto } from '@/features/auth/dto/signIn-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto'; import { SignOutUserDto } from '@/features/auth/dto/signOut-user.dto';
import { SingUpUserDto } from '@/features/auth/dto/signUp-user.dto';
import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto'; import { ValidateUserDto } from '@/features/auth/dto/validate-user.dto';
import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface'; import AuthTokensInterface from '@/features/auth/interfaces/auth-tokens.interface';
import { import {
@@ -23,14 +24,14 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { and, eq, or } from 'drizzle-orm'; import { 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 {
@@ -80,33 +81,43 @@ export class AuthService {
//Generate Tokens //Generate Tokens
async generateTokens(user: User): Promise<AuthTokensInterface> { async generateTokens(user: User): Promise<AuthTokensInterface> {
const accessTokenSecret = envs.access_token_secret ?? '';
const accessTokenExp = envs.access_token_expiration ?? '';
const refreshTokenSecret = envs.refresh_token_secret ?? '';
const refreshTokenExp = envs.refresh_token_expiration ?? '';
if (
!accessTokenSecret ||
!accessTokenExp ||
!refreshTokenSecret ||
!refreshTokenExp
) {
throw new Error('JWT environment variables are missing or invalid');
}
interface JwtPayload {
sub: number;
username: string;
}
const payload: JwtPayload = {
sub: Number(user?.id),
username: user.username ?? '',
};
const [access_token, refresh_token] = await Promise.all([ const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync( this.jwtService.signAsync(payload, {
{ secret: accessTokenSecret,
sub: user.id, expiresIn: accessTokenExp,
username: user.username, } as JwtSignOptions),
},
{ this.jwtService.signAsync(payload, {
secret: envs.access_token_secret, secret: refreshTokenSecret,
expiresIn: envs.access_token_expiration, expiresIn: refreshTokenExp,
}, } as JwtSignOptions),
),
this.jwtService.signAsync(
{
sub: user.id,
username: user.username,
},
{
secret: envs.refresh_token_secret,
expiresIn: envs.refresh_token_expiration,
},
),
]); ]);
return { return { access_token, refresh_token };
access_token,
refresh_token,
};
} }
//Generate OTP Code For Email Confirmation //Generate OTP Code For Email Confirmation
@@ -137,7 +148,8 @@ export class AuthService {
userId: parseInt(userId), userId: parseInt(userId),
expiresAt: sessionInput.expiresAt, expiresAt: sessionInput.expiresAt,
}); });
if (session.rowCount === 0) throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND); if (session.rowCount === 0)
throw new HttpException('Failed to create session', HttpStatus.NOT_FOUND);
return 'Session created successfully'; return 'Session created successfully';
} }
@@ -196,7 +208,6 @@ export class AuthService {
//Sign In User Account //Sign In User Account
async signIn(dto: SignInUserDto): Promise<LoginUserInterface> { async signIn(dto: SignInUserDto): Promise<LoginUserInterface> {
const user = await this.validateUser(dto); const user = await this.validateUser(dto);
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token); const decodeAccess = this.decodeToken(tokens.access_token);
@@ -261,18 +272,24 @@ export class AuthService {
} }
//Refresh User Access Token //Refresh User Access Token
async refreshToken(dto: RefreshTokenDto,user_id:number): Promise<RefreshTokenInterface> { async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
// const { user_id } = dto; const secret = envs.refresh_token_secret;
// const user_id = 1; const { user_id, token } = dto;
console.log('secret', secret);
console.log('refresh_token', token);
const validation = await this.jwtService.verifyAsync(token, {
secret,
});
if (!validation) throw new UnauthorizedException('Invalid refresh token');
const session = await this.drizzle const session = await this.drizzle
.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);
@@ -301,75 +318,83 @@ export class AuthService {
} }
async singUp(createUserDto: SingUpUserDto): Promise<User> { async singUp(createUserDto: SingUpUserDto): Promise<User> {
// Check if username or email exists // Check if username or email exists
const data = await this.drizzle const data = await this.drizzle
.select({
id: users.id,
username: users.username,
email: users.email,
})
.from(users)
.where(
or(
eq(users.username, createUserDto.username),
eq(users.email, createUserDto.email),
),
);
if (data.length > 0) {
if (data[0].username === createUserDto.username) {
throw new HttpException(
'Username already exists',
HttpStatus.BAD_REQUEST,
);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Hash the password
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// check if user role is admin
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({ .select({
id: users.id, id: users.id,
username: users.username, username: users.username,
email: users.email email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
}) })
.from(users) .from(users)
.where(or(eq(users.username, createUserDto.username), eq(users.email, createUserDto.email))); .leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
if (data.length > 0) { .where(eq(users.id, newUser.id));
if (data[0].username === createUserDto.username) {
throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
}
if (data[0].email === createUserDto.email) {
throw new HttpException('Email already exists', HttpStatus.BAD_REQUEST);
}
}
// Hash the password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
// Create the user
const [newUser] = await tx
.insert(users)
.values({
username: createUserDto.username,
email: createUserDto.email,
password: hashedPassword,
fullname: createUserDto.fullname,
isActive: true,
state: createUserDto.state,
municipality: createUserDto.municipality,
parish: createUserDto.parish,
phone: createUserDto.phone,
isEmailVerified: false,
isTwoFactorEnabled: false,
})
.returning();
// check if user role is admin
const role = createUserDto.role <= 2 ? 5 : createUserDto.role;
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: role,
});
// Return the created user with role
const [userWithRole] = await tx
.select({
id: users.id,
username: users.username,
email: users.email,
fullname: users.fullname,
phone: users.phone,
isActive: users.isActive,
role: roles.name,
})
.from(users)
.leftJoin(usersRole, eq(usersRole.userId, users.id))
.leftJoin(roles, eq(roles.id, usersRole.roleId))
.where(eq(users.id, newUser.id));
return userWithRole;
})
return userWithRole;
});
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,280 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsOptional,
IsString,
} from 'class-validator';
export class CreateTrainingDto {
// === 1. DATOS BÁSICOS ===
@ApiProperty()
@IsString()
firstname: string;
@ApiProperty()
@IsString()
lastname: string;
@ApiProperty()
@IsDateString()
visitDate: string; // Llega como string ISO "2024-11-11T10:00"
@ApiProperty()
@IsString()
@IsOptional()
coorPhone?: string;
// === 2. DATOS OSP ===
@ApiProperty()
@IsString()
ospName: string;
@ApiProperty()
@IsString()
ospRif: string;
@ApiProperty()
@IsString()
ospType: string; // 'UPF', etc.
@ApiProperty()
@IsString()
productiveActivity: string;
@ApiProperty()
@IsString()
currentStatus: string;
@ApiProperty()
@IsInt()
@Type(() => Number) // Convierte "2017" -> 2017
companyConstitutionYear: number;
@ApiProperty()
@IsString()
@IsOptional()
ospAddress: string;
@ApiProperty()
@IsString()
@IsOptional()
ospGoogleMapsLink?: string;
@ApiProperty()
@IsString()
@IsOptional()
infrastructureMt2?: string;
@ApiProperty()
@IsString()
@IsOptional()
structureType?: string;
@ApiProperty()
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true) // Convierte "false" -> false
hasTransport?: boolean;
@ApiProperty()
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
isOpenSpace?: boolean;
@ApiProperty()
@IsString()
@IsOptional()
paralysisReason?: string;
@ApiProperty()
@IsString()
@IsOptional()
generalObservations?: string;
// === 3. SECTORES ===
@ApiProperty()
@IsString()
ecoSector: string;
@ApiProperty()
@IsString()
productiveSector: string;
@ApiProperty()
@IsString()
centralProductiveActivity: string;
@ApiProperty()
@IsString()
mainProductiveActivity: string;
// === 4. DATOS RESPONSABLE ===
@ApiProperty()
@IsString()
ospResponsibleFullname: string;
@ApiProperty()
@IsString()
ospResponsibleCedula: string;
@ApiProperty()
@IsString()
ospResponsibleRif: string;
@ApiProperty()
@IsString()
ospResponsiblePhone: string;
@ApiProperty()
@IsString()
ospResponsibleEmail: string;
@ApiProperty()
@IsString()
civilState: string;
@ApiProperty()
@IsInt()
@Type(() => Number) // Convierte "3" -> 3
familyBurden: number;
@ApiProperty()
@IsInt()
@Type(() => Number)
numberOfChildren: number;
// === 5. COMUNA Y CONSEJO COMUNAL ===
@ApiProperty()
@IsString()
siturCodeCommune: string;
@ApiProperty()
@IsString()
communeName: string;
@ApiProperty()
@IsString()
communeRif: string;
@ApiProperty()
@IsString()
communeSpokespersonName: string;
@ApiProperty()
@IsString()
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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,539 @@
import { HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { and, eq, getTableColumns, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
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 { municipalities, parishes, states, trainingSurveys } from 'src/database/index';
// import XlsxPopulate from 'xlsx-populate';
import ExcelJS from 'exceljs';
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()
export class TrainingService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { }
async findAll(paginationDto?: PaginationDto) {
const {
page = 1,
limit = 10,
search = '',
sortBy = 'id',
sortOrder = 'asc',
} = paginationDto || {};
const offset = (page - 1) * limit;
let searchCondition: SQL<unknown> | undefined;
if (search) {
searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
}
const orderBy =
sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
const data = await this.drizzle
.select()
.from(trainingSurveys)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
filterDto;
const filters: SQL[] = [];
if (startDate)
filters.push(gte(trainingSurveys.visitDate, new Date(startDate)));
if (endDate)
filters.push(lte(trainingSurveys.visitDate, new Date(endDate)));
if (stateId) filters.push(eq(trainingSurveys.state, stateId));
if (municipalityId)
filters.push(eq(trainingSurveys.municipality, municipalityId));
if (parishId) filters.push(eq(trainingSurveys.parish, parishId));
if (ospType) filters.push(eq(trainingSurveys.ospType, ospType));
const whereCondition = filters.length > 0 ? and(...filters) : undefined;
// Ejecutamos todas las consultas en paralelo con Promise.all para mayor velocidad
const [
totalOspsResult,
totalProducersResult,
totalProductsResult, // Nuevo: Calculado desde el JSON
statusDistribution,
activityDistribution,
typeDistribution,
stateDistribution,
yearDistribution,
] = await Promise.all([
// 1. Total OSPs
this.drizzle
.select({ count: sql<number>`count(*)` })
.from(trainingSurveys)
.where(whereCondition),
// 2. Total Productores (Columna plana que mantuviste)
this.drizzle
.select({
sum: sql<number>`
SUM(
(
SELECT SUM(
COALESCE((item->>'menCount')::int, 0) +
COALESCE((item->>'womenCount')::int, 0)
)
FROM jsonb_array_elements(${trainingSurveys.productList}) as item
)
)
`,
})
.from(trainingSurveys)
.where(whereCondition),
// 3. NUEVO: Total Productos (Contamos el largo del array JSON productList)
this.drizzle
.select({
sum: sql<number>`sum(jsonb_array_length(${trainingSurveys.productList}))`,
})
.from(trainingSurveys)
.where(whereCondition),
// 4. Distribución por Estatus
this.drizzle
.select({
name: trainingSurveys.currentStatus,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.currentStatus),
// 5. Distribución por Actividad
this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.productiveActivity),
// 6. Distribución por Tipo
this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.ospType),
// 7. Distribución por Estado (CORREGIDO con COALESCE)
this.drizzle
.select({
// Si states.name es NULL, devuelve 'Sin Asignar'
name: sql<string>`COALESCE(${states.name}, 'Sin Asignar')`,
value: sql<number>`count(${trainingSurveys.id})`,
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
.where(whereCondition)
// Importante: Agrupar también por el resultado del COALESCE o por states.name
.groupBy(states.name),
// 8. Distribución por Año
this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
.groupBy(trainingSurveys.companyConstitutionYear)
.orderBy(trainingSurveys.companyConstitutionYear),
]);
return {
totalOsps: Number(totalOspsResult[0]?.count || 0),
totalProducers: Number(totalProducersResult[0]?.sum || 0),
totalProducts: Number(totalProductsResult[0]?.sum || 0), // Dato extraído del JSON
statusDistribution: statusDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
activityDistribution: activityDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
typeDistribution: typeDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
stateDistribution: stateDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
yearDistribution: yearDistribution.map((item) => ({
...item,
value: Number(item.value),
})),
};
}
async findOne(id: number) {
const find = await this.drizzle
.select({
...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,
);
}
return find[0];
}
private async saveFiles(files: Express.Multer.File[]): Promise<string[]> {
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;
});
}
// 2. If the user explicitly cleared a photo field (updateData.photoX === '')
photoFields.forEach((field) => {
if (updateData[field] === '') {
const oldPath = currentRecord[field];
if (oldPath) this.deleteFile(oldPath);
updateData[field] = null; // Set to null in DB
}
});
if (updateTrainingDto.visitDate) {
updateData.visitDate = new Date(updateTrainingDto.visitDate);
}
const [updatedRecord] = await this.drizzle
.update(trainingSurveys)
.set(updateData)
.where(eq(trainingSurveys.id, id))
.returning();
return updatedRecord;
}
async remove(id: number) {
const record = await this.findOne(id);
// Delete associated files
if (record.photo1) 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');
}
// Llenar los datos principales
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;
worksheet.getCell(record.hasTransport === true ? 'J19' : 'L19').value = 'X';
worksheet.getCell(record.structureType === 'CASA' ? 'J20' : 'L20').value =
'X';
worksheet.getCell(record.isOpenSpace === true ? 'J21' : 'L21').value = 'X';
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

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

View File

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

View File

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

@@ -0,0 +1,40 @@
import PageContainer from '@/components/layout/page-container';
import { TrainingHeader } from '@/feactures/training/components/training-header';
import TrainingList from '@/feactures/training/components/training-list';
import TrainingTableAction from '@/feactures/training/components/training-tables/training-table-action';
import { searchParamsCache } from '@repo/shadcn/lib/searchparams';
import { SearchParams } from 'nuqs';
import { env } from '@/lib/env';
export const metadata = {
title: 'Registro de OSP',
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page({ searchParams }: PageProps) {
const {
page,
q: searchQuery,
limit,
} = searchParamsCache.parse(await searchParams);
return (
// <PageContainer>
// <div className="flex flex-1 flex-col space-y-6">
< 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

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

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

@@ -1,3 +1,4 @@
// auth/actions/refresh-token-action.ts
'use server'; 'use server';
import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia
import { import {
@@ -7,7 +8,7 @@ import {
export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => { export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
try { try {
const response = await refreshApi.patch('/auth/refresh', {refresh_token: refreshToken.token}); const response = await refreshApi.patch('/auth/refresh', refreshToken);
const parsed = RefreshTokenResponseSchema.safeParse(response.data); const parsed = RefreshTokenResponseSchema.safeParse(response.data);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
// refreshtoken
import { z } from 'zod'; import { z } from 'zod';
import { tokensSchema } from './login'; import { tokensSchema } from './login';
// Esquema para el refresh token // Esquema para el refresh token
export const refreshTokenSchema = z.object({ export const refreshTokenSchema = z.object({
user_id: z.number(),
token: z.string(), token: z.string(),
}); });

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 ---
@@ -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,15 +31,15 @@ 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>
@@ -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

@@ -15,7 +15,7 @@ export function SurveysHeader() {
description="Gestiona las encuestas disponibles en la plataforma" description="Gestiona las encuestas disponibles en la plataforma"
/> />
<Button onClick={() => router.push(`/dashboard/administracion/encuestas/crear`)} size="sm"> <Button onClick={() => router.push(`/dashboard/administracion/encuestas/crear`)} size="sm">
<Plus className="mr-2 h-4 w-4" /> Agregar Encuesta <Plus className="h-4 w-4"/><span className='hidden sm:inline'>Agregar Encuesta</span>
</Button> </Button>
</div> </div>

View File

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

View File

@@ -0,0 +1,41 @@
-- datos basicos
nombre,
apellido,
fecha de la visita,
-->Falta
hora de la visita,
-- datos de la ubicacion
estado,
municipio,
parroquia,
nombre de la comuna,
CODIGO SITUR COMUNA,
CONSEJO COMUNAL,
CODIGO SITUR CONSEJO COMUNAL,
-- datos de la osp
actividad productiva (agricola,textil,bloquera,carpinteria,unidad de suministro),
realice una breve descripcion del requerimiento financiero,
NOMBRE DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
DIRECCIÓN DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
RIF DE LA ORGANIZACIÓN SOCIOPRODUCTIVA,
TIPO DE ORGANIZACIÓN SOCIOPRODUCTIVA,
ESTATUS ACTUAL,
AÑO DE CONSTITUCIÓN DE LA EMPRESA ,
CANTIDAD DE PRODUCTORES QUE LA CONFORMAN,
BREVE DESCRIPCIÓN DEL PRODUCTO O SERVICIO QUE OFRECE,
CAPACIDAD INSTALADA,
CAPACIDAD OPERATIVA,
¿EXPLIQUE LAS RAZONES GENERALES POR LAS CUALES LA UNIDAD DE PRODUCCIÓN TUVO QUE PARALIZARSE?
-- datos del responsable
NOMBRE Y APELLIDO DEL RESPONSABLE DE LA OSP,
CÉDULA DEL RESPONSABLE (SIN PUNTOS),
RIF DEL RESPONSABLE (SIN PUNTOS),
TELÉFONOS (COLOQUE 2 NUMEROS DE TELEFONOS),
CORREO ELECTRÓNICO,
ESTADO CIVIL DEL PRODUCTOR,
CARGA FAMILIAR,
NUMERO DE HIJOS,
-- datos adicionales
OBSERVACIONES GENERALES,
-- fotos
COLOCAR TRES (3) REGISTROS FOTOGRÁFICOS VISIBLES DEL ESPACIO Y MAQUINARIAS ACTUALMENTE (OBLIGATORIO),

View File

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

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

View File

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

@@ -0,0 +1,13 @@
import { useSafeQuery } from '@/hooks/use-safe-query';
import { getTrainingStatisticsAction } from '../actions/training-actions';
export function useTrainingStatsQuery(params: {
startDate?: string;
endDate?: string;
stateId?: number;
municipalityId?: number;
parishId?: number;
ospType?: string;
} = {}) {
return useSafeQuery(['training-statistics', JSON.stringify(params)], () => getTrainingStatisticsAction(params));
}

View File

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

View File

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

View File

@@ -0,0 +1,186 @@
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({
//Datos de la visita
id: z.number().optional(),
firstname: z.string().min(1, { message: 'Nombre es requerido' }),
lastname: z.string().min(1, { message: 'Apellido es requerido' }),
coorPhone: z.string().optional().nullable(),
visitDate: z
.string()
.min(1, { message: 'Fecha y hora de visita es requerida' }),
//Datos de la organización socioproductiva (OSP)
ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
ecoSector: z.string().optional().or(z.literal('')),
productiveSector: z.string().optional().or(z.literal('')),
centralProductiveActivity: z.string().optional().or(z.literal('')),
mainProductiveActivity: z.string().optional().or(z.literal('')),
productiveActivity: z
.string()
.min(1, { message: 'Actividad productiva es requerida' }),
ospRif: z.string().optional().or(z.literal('')),
ospName: z.string().min(1, { message: 'Nombre de la OSP es requerido' }),
companyConstitutionYear: z.coerce
.number()
.min(1900, { message: 'Año inválido' }),
currentStatus: z
.string()
.min(1, { message: 'Estatus actual es requerido' })
.default('ACTIVA'),
infrastructureMt2: z.string().optional().or(z.literal('')),
hasTransport: z
.preprocess((val) => val === 'true' || val === true, z.boolean())
.optional(),
structureType: z.string().optional().or(z.literal('')),
isOpenSpace: z
.preprocess((val) => val === 'true' || val === true, z.boolean())
.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 const trainingApiResponseSchema = z.object({
message: z.string(),
data: z.array(trainingSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
totalCount: z.number(),
totalPages: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
nextPage: z.number().nullable(),
previousPage: z.number().nullable(),
}),
});
export const TrainingMutate = z.object({
message: z.string(),
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

@@ -16,7 +16,7 @@ export function UsersHeader() {
description="Gestiona los usuarios registrados en la plataforma" description="Gestiona los usuarios registrados en la plataforma"
/> />
<Button onClick={() => setOpen(true)} size="sm"> <Button onClick={() => setOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> Agregar Usuario <Plus className="h-4 w-4"/><span className='hidden sm:inline'>Agregar Usuario</span>
</Button> </Button>
</div> </div>

View File

@@ -78,9 +78,7 @@ export function ModalForm({
parish: undefined parish: undefined
} }
// console.log(defaultValues);
console.log(defaultValues);
const form = useForm<UpdateUser>({ const form = useForm<UpdateUser>({
resolver: zodResolver(updateUser), resolver: zodResolver(updateUser),

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}
onOpenChange={setOpen}
defaultValues={data?.data}
/>
<h2 className='mt-3 mb-1'>Datos del usuario</h2> <h2 className="mt-3 mb-1">Datos del usuario</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 "> <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,7 +1,7 @@
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';
@@ -91,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,
}; };
}, },
}), }),
], ],
@@ -100,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) {
@@ -119,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
@@ -130,43 +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 res = await resfreshTokenAction({ token: token.refresh_token as string }); const refresh_token = { token: token.refresh_token as string, user_id: Number(token.id) as number }
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
} }
}, },
@@ -182,9 +165,11 @@ 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;
}, },
}, },
} satisfies NextAuthConfig; } satisfies NextAuthConfig;
export default authConfig; export default authConfig;

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": {