mejoras al formulario de registro organizaciones productivas

This commit is contained in:
2026-01-22 14:28:24 -04:00
parent 69b3aab02a
commit 08a5567d60
34 changed files with 4297 additions and 1102 deletions

3
apps/api/.gitignore vendored
View File

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

View File

@@ -49,7 +49,8 @@
"pg": "8.13.3",
"pino-pretty": "13.0.0",
"reflect-metadata": "0.2.0",
"rxjs": "7.8.1"
"rxjs": "7.8.1",
"sharp": "^0.34.5"
},
"devDependencies": {
"@nestjs-modules/mailer": "^2.0.2",

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1764883378610,
"tag": "0009_eminent_ares",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1769097895095,
"tag": "0010_dashing_bishop",
"breakpoints": true
}
]
}

View File

@@ -1,9 +1,8 @@
import { sql } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core';
import { eq, lt, gte, ne, sql } from 'drizzle-orm';
import { timestamps } from '../timestamps';
import { users } from './auth';
import { states, municipalities, parishes } from './general';
import { municipalities, parishes, states } from './general';
// Tabla surveys
export const surveys = t.pgTable(
@@ -19,9 +18,7 @@ export const surveys = t.pgTable(
...timestamps,
},
(surveys) => ({
surveysIndex: t
.index('surveys_index_00')
.on(surveys.title),
surveysIndex: t.index('surveys_index_00').on(surveys.title),
}),
);
@@ -55,9 +52,15 @@ export const trainingSurveys = t.pgTable(
lastname: t.text('lastname').notNull(),
visitDate: t.timestamp('visit_date').notNull(),
// ubicacion
state: t.integer('state').references(() => states.id, { onDelete: 'set null' }),
municipality: t.integer('municipality').references(() => municipalities.id, { onDelete: 'set null' }),
parish: t.integer('parish').references(() => parishes.id, { onDelete: 'set null' }),
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' }),
siturCodeCommune: t.text('situr_code_commune').notNull(),
communalCouncil: t.text('communal_council').notNull(),
siturCodeCommunalCouncil: t.text('situr_code_communal_council').notNull(),
@@ -67,10 +70,13 @@ export const trainingSurveys = t.pgTable(
ospRif: t.text('osp_rif').notNull(),
ospType: t.text('osp_type').notNull(),
productiveActivity: t.text('productive_activity').notNull(),
financialRequirementDescription: t.text('financial_requirement_description').notNull(),
currentStatus: t.text('current_status').notNull(),
financialRequirementDescription: t
.text('financial_requirement_description')
.notNull(),
currentStatus: t.text('current_status').notNull().default('ACTIVA'),
companyConstitutionYear: t.integer('company_constitution_year').notNull(),
producerCount: t.integer('producer_count').notNull(),
productCount: t.integer('product_count').notNull().default(0),
productDescription: t.text('product_description').notNull(),
installedCapacity: t.text('installed_capacity').notNull(),
operationalCapacity: t.text('operational_capacity').notNull(),
@@ -88,13 +94,15 @@ export const trainingSurveys = t.pgTable(
paralysisReason: t.text('paralysis_reason').notNull(),
// fotos
photo1: t.text('photo1').notNull(),
photo2: t.text('photo2').notNull(),
photo3: t.text('photo3').notNull(),
photo2: t.text('photo2'),
photo3: t.text('photo3'),
...timestamps,
},
(trainingSurveys) => ({
trainingSurveysIndex: t.index('training_surveys_index_00').on(trainingSurveys.firstname),
})
trainingSurveysIndex: t
.index('training_surveys_index_00')
.on(trainingSurveys.firstname),
}),
);
export const viewSurveys = t.pgView('v_surveys', {
@@ -103,6 +111,7 @@ export const viewSurveys = t.pgView('v_surveys', {
description: t.text('description'),
created_at: t.timestamp('created_at'),
closingDate: t.date('closing_date'),
targetAudience: t.varchar('target_audience')
}).as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
targetAudience: t.varchar('target_audience'),
})
.as(sql`select id as survey_id, title, description, created_at, closing_date, target_audience from surveys
where published = true`);

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsString, IsDateString, IsOptional } from 'class-validator';
import { IsDateString, IsInt, IsOptional, IsString } from 'class-validator';
export class CreateTrainingDto {
@ApiProperty()
@@ -74,6 +74,11 @@ export class CreateTrainingDto {
@IsInt()
producerCount: number;
@ApiProperty()
@IsInt()
@IsOptional()
productCount: number;
@ApiProperty()
@IsString()
productDescription: string;
@@ -124,17 +129,21 @@ export class CreateTrainingDto {
@ApiProperty()
@IsString()
photo1: string;
@IsOptional()
photo1?: string;
@ApiProperty()
@IsString()
photo2: string;
@IsOptional()
photo2?: string;
@ApiProperty()
@IsString()
photo3: string;
@IsOptional()
photo3?: string;
@ApiProperty()
@IsString()
@IsOptional()
paralysisReason: string;
}

View File

@@ -1,11 +1,28 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common';
import { TrainingService } from './training.service';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { ImageProcessingPipe } from '../../common/pipes/image-processing.pipe';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingService } from './training.service';
@ApiTags('training')
@Controller('training')
@@ -13,14 +30,19 @@ export class TrainingController {
constructor(private readonly trainingService: TrainingService) {}
@Get()
@ApiOperation({ summary: 'Get all training records with pagination and filters' })
@ApiResponse({ status: 200, description: 'Return paginated training records.' })
@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
meta: result.meta,
};
}
@@ -42,25 +64,49 @@ export class TrainingController {
}
@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) {
const data = await this.trainingService.create(createTrainingDto);
@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 };
}
@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: 200,
description: 'Training record updated successfully.',
})
@ApiResponse({ status: 404, description: 'Training record not found.' })
async update(@Param('id') id: string, @Body() updateTrainingDto: UpdateTrainingDto) {
const data = await this.trainingService.update(+id, updateTrainingDto);
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(':id')
@ApiOperation({ summary: 'Delete a training record' })
@ApiResponse({ status: 200, description: 'Training record deleted successfully.' })
@ApiResponse({
status: 200,
description: 'Training record deleted successfully.',
})
@ApiResponse({ status: 404, description: 'Training record not found.' })
async remove(@Param('id') id: string) {
return await this.trainingService.remove(+id);

View File

@@ -1,14 +1,15 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * 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 { trainingSurveys } from 'src/database/index';
import { eq, like, or, and, gte, lte, SQL, sql } from 'drizzle-orm';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { states } from 'src/database/index';
import { states, trainingSurveys } from 'src/database/index';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
@Injectable()
export class TrainingService {
@@ -16,23 +17,24 @@ export class TrainingService {
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) {}
async findAll(paginationDto?: PaginationDto) {
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = 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(
like(trainingSurveys.firstname, `%${search}%`),
like(trainingSurveys.lastname, `%${search}%`),
like(trainingSurveys.ospName, `%${search}%`),
like(trainingSurveys.ospRif, `%${search}%`)
);
searchCondition = or(ilike(trainingSurveys.ospName, `%${search}%`));
}
const orderBy = sortOrder === 'asc'
const orderBy =
sortOrder === 'asc'
? sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} asc`
: sql`${trainingSurveys[sortBy as keyof typeof trainingSurveys]} desc`;
@@ -67,7 +69,8 @@ export class TrainingService {
}
async getStatistics(filterDto: TrainingStatisticsFilterDto) {
const { startDate, endDate, stateId, municipalityId, parishId, ospType } = filterDto;
const { startDate, endDate, stateId, municipalityId, parishId, ospType } =
filterDto;
const filters: SQL[] = [];
@@ -112,7 +115,7 @@ export class TrainingService {
const statusDistribution = await this.drizzle
.select({
name: trainingSurveys.currentStatus,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
@@ -121,7 +124,7 @@ export class TrainingService {
const activityDistribution = await this.drizzle
.select({
name: trainingSurveys.productiveActivity,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
@@ -130,7 +133,7 @@ export class TrainingService {
const typeDistribution = await this.drizzle
.select({
name: trainingSurveys.ospType,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
@@ -140,7 +143,7 @@ export class TrainingService {
const stateDistribution = await this.drizzle
.select({
name: states.name,
value: sql<number>`count(${trainingSurveys.id})`
value: sql<number>`count(${trainingSurveys.id})`,
})
.from(trainingSurveys)
.leftJoin(states, eq(trainingSurveys.state, states.id))
@@ -150,7 +153,7 @@ export class TrainingService {
const yearDistribution = await this.drizzle
.select({
name: sql<string>`cast(${trainingSurveys.companyConstitutionYear} as text)`,
value: sql<number>`count(*)`
value: sql<number>`count(*)`,
})
.from(trainingSurveys)
.where(whereCondition)
@@ -160,11 +163,26 @@ export class TrainingService {
return {
totalOsps,
totalProducers,
statusDistribution: statusDistribution.map(item => ({ ...item, value: Number(item.value) })),
activityDistribution: activityDistribution.map(item => ({ ...item, value: Number(item.value) })),
typeDistribution: typeDistribution.map(item => ({ ...item, value: Number(item.value) })),
stateDistribution: stateDistribution.map(item => ({ ...item, value: Number(item.value) })),
yearDistribution: yearDistribution.map(item => ({ ...item, value: Number(item.value) })),
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),
})),
};
}
@@ -175,28 +193,103 @@ export class TrainingService {
.where(eq(trainingSurveys.id, id));
if (find.length === 0) {
throw new HttpException('Training record not found', HttpStatus.NOT_FOUND);
throw new HttpException(
'Training record not found',
HttpStatus.NOT_FOUND,
);
}
return find[0];
}
async create(createTrainingDto: CreateTrainingDto) {
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)}${path.extname(file.originalname)}`;
const filePath = path.join(uploadDir, fileName);
fs.writeFileSync(filePath, file.buffer);
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[],
) {
const photoPaths = await this.saveFiles(files);
const [newRecord] = await this.drizzle
.insert(trainingSurveys)
.values({
...createTrainingDto,
visitDate: new Date(createTrainingDto.visitDate),
photo1: photoPaths[0] || '',
photo2: photoPaths[1] || null,
photo3: photoPaths[2] || null,
})
.returning();
return newRecord;
}
async update(id: number, updateTrainingDto: UpdateTrainingDto) {
await this.findOne(id);
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);
}
@@ -211,13 +304,21 @@ export class TrainingService {
}
async remove(id: number) {
await this.findOne(id);
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 };
return {
message: 'Training record deleted successfully',
data: deletedRecord,
};
}
}

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="flex-1 space-y-4">
<CreateTrainingForm
defaultValues={training}
onSuccess={() => router.push('/dashboard/formulario')}
onCancel={() => router.back()}
/>
</div>
</PageContainer>
);
}

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import { useSession } from 'next-auth/react';
export const company = {
name: 'Sistema para Productores',
name: 'Sistema de Productores',
logo: GalleryVerticalEnd,
plan: 'FONDEMI',
};

View File

@@ -26,7 +26,7 @@ export const AdministrationItems: NavItem[] = [
url: '#', // Placeholder as there is no direct link for the parent
icon: 'settings2',
isActive: true,
role: ['admin', 'superadmin', 'manager', 'autoridad'], // sumatoria de los roles que si tienen acceso
role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'], // sumatoria de los roles que si tienen acceso
items: [
{
@@ -41,14 +41,14 @@ export const AdministrationItems: NavItem[] = [
shortcut: ['l', 'l'],
url: '/dashboard/administracion/encuestas',
icon: 'login',
role: ['admin', 'superadmin', 'autoridad', 'manager'],
role: ['admin', 'superadmin', 'autoridad'],
},
{
title: 'Registro OSP',
shortcut: ['p', 'p'],
url: '/dashboard/formulario/',
icon: 'notepadText',
role: ['admin', 'superadmin', 'manager', 'autoridad'],
role: ['admin', 'superadmin', 'manager', 'autoridad', 'coordinators'],
},
],
},
@@ -60,7 +60,7 @@ export const StatisticsItems: NavItem[] = [
url: '#', // Placeholder as there is no direct link for the parent
icon: 'chartColumn',
isActive: true,
role: ['admin', 'superadmin', 'autoridad'],
role: ['admin', 'superadmin', 'autoridad', 'manager'],
items: [
// {
@@ -82,7 +82,7 @@ export const StatisticsItems: NavItem[] = [
shortcut: ['s', 's'],
url: '/dashboard/estadisticas/socioproductiva',
icon: 'blocks',
role: ['admin', 'superadmin', 'autoridad'],
role: ['admin', 'superadmin', 'autoridad', 'manager'],
},
],
},

View File

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

View File

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

View File

@@ -1,26 +1,30 @@
'use server';
import { safeFetchApi } from '@/lib/fetch.api';
import {
TrainingSchema,
TrainingMutate,
trainingApiResponseSchema
} from '../schemas/training';
import { trainingStatisticsResponseSchema } from '../schemas/statistics';
import {
TrainingMutate,
TrainingSchema,
trainingApiResponseSchema,
} from '../schemas/training';
export const getTrainingStatisticsAction = async (params: {
export const getTrainingStatisticsAction = async (
params: {
startDate?: string;
endDate?: string;
stateId?: number;
municipalityId?: number;
parishId?: number;
ospType?: string;
} = {}) => {
} = {},
) => {
const searchParams = new URLSearchParams();
if (params.startDate) searchParams.append('startDate', params.startDate);
if (params.endDate) searchParams.append('endDate', params.endDate);
if (params.stateId) searchParams.append('stateId', params.stateId.toString());
if (params.municipalityId) searchParams.append('municipalityId', params.municipalityId.toString());
if (params.parishId) searchParams.append('parishId', params.parishId.toString());
if (params.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(
@@ -32,8 +36,7 @@ export const getTrainingStatisticsAction = async (params: {
if (error) throw new Error(error.message);
return response?.data;
}
};
export const getTrainingAction = async (params: {
page?: number;
@@ -42,7 +45,6 @@ export const getTrainingAction = async (params: {
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
@@ -72,16 +74,27 @@ export const getTrainingAction = async (params: {
previousPage: null,
},
};
}
};
export const createTrainingAction = async (payload: TrainingSchema) => {
const { id, ...payloadWithoutId } = payload;
export const createTrainingAction = async (
payload: TrainingSchema | FormData,
) => {
let payloadToSend = payload;
let id: number | undefined;
if (payload instanceof FormData) {
payload.delete('id');
payloadToSend = payload;
} else {
const { id: _, ...rest } = payload;
payloadToSend = rest as any;
}
const [error, data] = await safeFetchApi(
TrainingMutate,
'/training',
'POST',
payloadWithoutId,
payloadToSend,
);
if (error) {
@@ -91,8 +104,21 @@ export const createTrainingAction = async (payload: TrainingSchema) => {
return data;
};
export const updateTrainingAction = async (payload: TrainingSchema) => {
const { id, ...payloadWithoutId } = payload;
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');
@@ -100,7 +126,7 @@ export const updateTrainingAction = async (payload: TrainingSchema) => {
TrainingMutate,
`/training/${id}`,
'PATCH',
payloadWithoutId,
payloadToSend,
);
if (error) {
@@ -114,10 +140,22 @@ export const deleteTrainingAction = async (id: number) => {
const [error] = await safeFetchApi(
TrainingMutate,
`/training/${id}`,
'DELETE'
)
'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;
};

File diff suppressed because it is too large Load Diff

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,39 @@
'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;
}
export default function TrainingList({
initialPage,
initialSearch,
initialLimit,
}: 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}
data={data?.data || []}
totalItems={data?.meta.totalCount || 0}
pageSizeOptions={[10, 20, 30, 40, 50]}
/>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { useDeleteTraining } from '@/feactures/training/hooks/use-training';
import { TrainingSchema } from '@/feactures/training/schemas/training';
import { Button } from '@repo/shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn/tooltip';
import { Edit, Eye, Trash } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { TrainingViewModal } from '../training-view-modal';
interface CellActionProps {
data: TrainingSchema;
}
export const CellAction: React.FC<CellActionProps> = ({ data }) => {
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);
}
};
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={() =>
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,45 @@
'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';
export const columns: ColumnDef<TrainingSchema>[] = [
{
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} />,
},
];

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" />
Nuevo Registro
</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,285 @@
'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 { X } from 'lucide-react';
import React, { useState } from 'react';
import { TrainingSchema } from '../schemas/training';
interface TrainingViewModalProps {
data: TrainingSchema | null;
isOpen: boolean;
onClose: () => void;
}
export function TrainingViewModal({
data,
isOpen,
onClose,
}: TrainingViewModalProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
if (!data) return null;
const DetailItem = ({ label, value }: { label: string; value: any }) => (
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="text-sm font-semibold">{value || 'N/A'}</p>
</div>
);
const Section = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<Card className="mb-4">
<CardHeader className="py-3">
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{children}
</CardContent>
</Card>
);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[800px] overflow-y-auto [&>button:last-child]:hidden">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Detalle de la Organización Socioproductiva
</DialogTitle>
<DialogDescription className="sr-only">
Resumen detallado de la información de la organización
socioproductiva incluyendo ubicación, responsable y registro
fotográfico.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-6">
{/* 1. Datos de la visita */}
<Section title="1. Datos de la visita">
<DetailItem
label="Nombre del Coordinador"
value={data.firstname}
/>
<DetailItem
label="Apellido del Coordinador"
value={data.lastname}
/>
<DetailItem
label="Fecha y hora de la visita"
value={
data.visitDate
? new Date(data.visitDate).toLocaleString()
: 'N/A'
}
/>
</Section>
{/* 2. Datos de la OSP */}
<Section title="2. Datos de la OSP">
<DetailItem label="Nombre" value={data.ospName} />
<DetailItem label="RIF" value={data.ospRif} />
<DetailItem label="Tipo" value={data.ospType} />
<DetailItem
label="Actividad Productiva"
value={data.productiveActivity}
/>
<DetailItem
label="Estatus"
value={
<Badge
variant={
data.currentStatus === 'ACTIVA' ? 'default' : 'secondary'
}
>
{data.currentStatus}
</Badge>
}
/>
<DetailItem
label="Año de Constitución"
value={data.companyConstitutionYear}
/>
<DetailItem
label="Cant. Productores"
value={data.producerCount}
/>
<DetailItem label="Cant. Productos" value={data.productCount} />
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem
label="Descripción del Producto"
value={data.productDescription}
/>
</div>
<DetailItem
label="Capacidad Instalada"
value={data.installedCapacity}
/>
<DetailItem
label="Capacidad Operativa"
value={data.operationalCapacity}
/>
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem
label="Requerimiento Financiero"
value={data.financialRequirementDescription}
/>
</div>
{data.paralysisReason && (
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem
label="Razones de paralización"
value={data.paralysisReason}
/>
</div>
)}
</Section>
{/* 3. Ubicación */}
<Section title="3. Detalles de la ubicación">
<DetailItem
label="Código SITUR Comuna"
value={data.siturCodeCommune}
/>
<DetailItem
label="Consejo Comunal"
value={data.communalCouncil}
/>
<DetailItem
label="Código SITUR Consejo Comunal"
value={data.siturCodeCommunalCouncil}
/>
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem label="Dirección OSP" value={data.ospAddress} />
</div>
</Section>
{/* 4. Responsable */}
<Section title="4. Datos del Responsable">
<DetailItem
label="Nombre Completo"
value={data.ospResponsibleFullname}
/>
<DetailItem label="Cédula" value={data.ospResponsibleCedula} />
<DetailItem label="RIF" value={data.ospResponsibleRif} />
<DetailItem label="Estado Civil" value={data.civilState} />
<DetailItem label="Teléfono" value={data.ospResponsiblePhone} />
<DetailItem label="Email" value={data.ospResponsibleEmail} />
<DetailItem label="Carga Familiar" value={data.familyBurden} />
<DetailItem
label="Número de Hijos"
value={data.numberOfChildren}
/>
</Section>
{/* 5. Observaciones */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-lg">
5. Observaciones Generales
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{data.generalObservations || 'Sin observaciones'}
</p>
</CardContent>
</Card>
{/* 6. Registro fotográfico */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-lg">
6. Registro fotográfico
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{[data.photo1, data.photo2, data.photo3].map(
(photo, idx) =>
photo && (
<div
key={idx}
className="relative aspect-video rounded-md overflow-hidden cursor-pointer hover:opacity-90 transition-opacity bg-muted"
onClick={() => setSelectedImage(photo)}
>
<img
src={`${process.env.NEXT_PUBLIC_API_URL}${photo}`}
alt={`Registro ${idx + 1}`}
className="object-cover w-full h-full"
/>
</div>
),
)}
{![data.photo1, data.photo2, data.photo3].some(Boolean) && (
<p className="text-sm text-muted-foreground">
No hay registro fotográfico
</p>
)}
</div>
</CardContent>
</Card>
</div>
<DialogFooter className="mt-6">
<Button onClick={onClose} variant="outline" className="w-32">
Cerrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Lightbox */}
<Dialog
open={!!selectedImage}
onOpenChange={() => setSelectedImage(null)}
>
<DialogContent className="max-w-[95vw] max-h-[95vh] p-0 overflow-hidden bg-black/90 border-none [&>button:last-child]:hidden">
<DialogHeader className="sr-only">
<DialogTitle>Vista ampliada de la imagen</DialogTitle>
<DialogDescription>
Imagen ampliada del registro fotográfico de la organización.
</DialogDescription>
</DialogHeader>
<div className="relative w-full h-full flex items-center justify-center p-4">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 text-white hover:bg-white/20 z-10"
onClick={() => setSelectedImage(null)}
>
<X className="h-6 w-6" />
</Button>
{selectedImage && (
<img
src={`${process.env.NEXT_PUBLIC_API_URL}${selectedImage}`}
alt="Expanded view"
className="max-w-full max-h-[90vh] object-contain"
/>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

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

View File

@@ -2,36 +2,82 @@ import { z } from 'zod';
export const trainingSchema = z.object({
id: z.number().optional(),
firstname: z.string().min(1, { message: "Nombre es requerido" }),
lastname: z.string().min(1, { message: "Apellido es requerido" }),
visitDate: z.string().or(z.date()).transform((val) => new Date(val).toISOString()),
productiveActivity: z.string().min(1, { message: "Actividad productiva es requerida" }),
financialRequirementDescription: z.string().min(1, { message: "Descripción es requerida" }),
siturCodeCommune: z.string().min(1, { message: "Código SITUR Comuna es requerido" }),
communalCouncil: z.string().min(1, { message: "Consejo Comunal es requerido" }),
siturCodeCommunalCouncil: z.string().min(1, { message: "Código SITUR Consejo Comunal es requerido" }),
ospName: z.string().min(1, { message: "Nombre de la OSP es requerido" }),
ospAddress: z.string().min(1, { message: "Dirección de la OSP es requerida" }),
ospRif: z.string().min(1, { message: "RIF de la OSP es requerido" }),
ospType: z.string().min(1, { message: "Tipo de OSP es requerido" }),
currentStatus: z.string().min(1, { message: "Estatus actual es requerido" }),
companyConstitutionYear: z.coerce.number().min(1900, { message: "Año inválido" }),
producerCount: z.coerce.number().min(1, { message: "Cantidad de productores requerida" }),
productDescription: z.string().min(1, { message: "Descripción del producto es requerida" }),
installedCapacity: z.string().min(1, { message: "Capacidad instalada es requerida" }),
operationalCapacity: z.string().min(1, { message: "Capacidad operativa es requerida" }),
ospResponsibleFullname: z.string().min(1, { message: "Nombre del responsable es requerido" }),
ospResponsibleCedula: z.string().min(1, { message: "Cédula del responsable es requerida" }),
ospResponsibleRif: z.string().min(1, { message: "RIF del responsable es requerido" }),
ospResponsiblePhone: z.string().min(1, { message: "Teléfono del responsable es requerido" }),
civilState: z.string().min(1, { message: "Estado civil es requerido" }),
familyBurden: z.coerce.number().min(0, { message: "Carga familiar requerida" }),
numberOfChildren: z.coerce.number().min(0, { message: "Número de hijos requerido" }),
ospResponsibleEmail: z.string().email({ message: "Correo electrónico inválido" }),
firstname: z.string().min(1, { message: 'Nombre es requerido' }),
lastname: z.string().min(1, { message: 'Apellido es requerido' }),
visitDate: z
.string()
.min(1, { message: 'Fecha y hora de visita es requerida' }),
productiveActivity: z
.string()
.min(1, { message: 'Actividad productiva es requerida' }),
financialRequirementDescription: z
.string()
.min(1, { message: 'Descripción es requerida' }),
siturCodeCommune: z
.string()
.min(1, { message: 'Código SITUR Comuna es requerido' }),
communalCouncil: z
.string()
.min(1, { message: 'Consejo Comunal es requerido' }),
siturCodeCommunalCouncil: z
.string()
.min(1, { message: 'Código SITUR Consejo Comunal es requerido' }),
ospName: z.string().min(1, { message: 'Nombre de la OSP es requerido' }),
ospAddress: z
.string()
.min(1, { message: 'Dirección de la OSP es requerida' }),
ospRif: z.string().min(1, { message: 'RIF de la OSP es requerido' }),
ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
currentStatus: z
.string()
.min(1, { message: 'Estatus actual es requerido' })
.default('ACTIVA'),
companyConstitutionYear: z.coerce
.number()
.min(1900, { message: 'Año inválido' }),
producerCount: z.coerce
.number()
.min(0, { message: 'Cantidad de productores requerida' }),
productCount: z.coerce
.number()
.min(0, { message: 'Cantidad de productos requerida' })
.optional(),
productDescription: z
.string()
.min(1, { message: 'Descripción del producto es requerida' }),
installedCapacity: z
.string()
.min(1, { message: 'Capacidad instalada es requerida' }),
operationalCapacity: z
.string()
.min(1, { message: 'Capacidad operativa es requerida' }),
ospResponsibleFullname: z
.string()
.min(1, { message: 'Nombre del responsable es requerido' }),
ospResponsibleCedula: z
.string()
.min(1, { message: 'Cédula del responsable es requerida' }),
ospResponsibleRif: z
.string()
.min(1, { message: 'RIF del responsable es requerido' }),
ospResponsiblePhone: z
.string()
.min(1, { message: 'Teléfono del responsable es requerido' }),
civilState: z.string().min(1, { message: 'Estado civil es requerido' }),
familyBurden: z.coerce
.number()
.min(0, { message: 'Carga familiar requerida' }),
numberOfChildren: z.coerce
.number()
.min(0, { message: 'Número de hijos requerido' }),
ospResponsibleEmail: z
.string()
.email({ message: 'Correo electrónico inválido' }),
generalObservations: z.string().optional().default(''),
photo1: z.string().optional().default(''),
photo2: z.string().optional().default(''),
photo3: z.string().optional().default(''),
photo1: z.string().optional().nullable(),
photo2: z.string().optional().nullable(),
photo3: z.string().optional().nullable(),
files: z.any().optional(),
paralysisReason: z.string().optional().default(''),
state: z.number().optional().nullable(),
municipality: z.number().optional().nullable(),

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB