anexado guardar en minio y cambios generales en la interfaz de osp

This commit is contained in:
2026-02-24 11:00:50 -04:00
parent fed90d9ff1
commit c70e146ce2
22 changed files with 5139 additions and 696 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -141,6 +141,20 @@
"when": 1771858973096, "when": 1771858973096,
"tag": "0019_cuddly_cobalt_man", "tag": "0019_cuddly_cobalt_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1771897944334,
"tag": "0020_certain_bushwacker",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1771901546945,
"tag": "0021_warm_machine_man",
"breakpoints": true
} }
] ]
} }

View File

@@ -77,8 +77,8 @@ export const trainingSurveys = t.pgTable(
.notNull() .notNull()
.default(''), .default(''),
productiveActivity: t.text('productive_activity').notNull(), productiveActivity: t.text('productive_activity').notNull(),
ospRif: t.text('osp_rif').notNull(), ospRif: t.text('osp_rif'),
ospName: t.text('osp_name').notNull(), ospName: t.text('osp_name'),
companyConstitutionYear: t.integer('company_constitution_year').notNull(), companyConstitutionYear: t.integer('company_constitution_year').notNull(),
currentStatus: t.text('current_status').notNull().default('ACTIVA'), currentStatus: t.text('current_status').notNull().default('ACTIVA'),
infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''), infrastructureMt2: t.text('infrastructure_mt2').notNull().default(''),
@@ -98,10 +98,8 @@ export const trainingSurveys = t.pgTable(
.text('commune_spokesperson_name') .text('commune_spokesperson_name')
.notNull() .notNull()
.default(''), .default(''),
communeSpokespersonCedula: t communeSpokespersonCedula: t.text('commune_spokesperson_cedula'),
.text('commune_spokesperson_cedula'), communeSpokespersonRif: t.text('commune_spokesperson_rif'),
communeSpokespersonRif: t
.text('commune_spokesperson_rif'),
communeSpokespersonPhone: t communeSpokespersonPhone: t
.text('commune_spokesperson_phone') .text('commune_spokesperson_phone')
.notNull() .notNull()
@@ -114,10 +112,10 @@ export const trainingSurveys = t.pgTable(
.text('communal_council_spokesperson_name') .text('communal_council_spokesperson_name')
.notNull() .notNull()
.default(''), .default(''),
communalCouncilSpokespersonCedula: t communalCouncilSpokespersonCedula: t.text(
.text('communal_council_spokesperson_cedula'), 'communal_council_spokesperson_cedula',
communalCouncilSpokespersonRif: t ),
.text('communal_council_spokesperson_rif'), communalCouncilSpokespersonRif: t.text('communal_council_spokesperson_rif'),
communalCouncilSpokespersonPhone: t communalCouncilSpokespersonPhone: t
.text('communal_council_spokesperson_phone') .text('communal_council_spokesperson_phone')
.notNull() .notNull()
@@ -128,20 +126,24 @@ export const trainingSurveys = t.pgTable(
.default(''), .default(''),
ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(), ospResponsibleFullname: t.text('osp_responsible_fullname').notNull(),
ospResponsibleCedula: t.text('osp_responsible_cedula').notNull(), ospResponsibleCedula: t.text('osp_responsible_cedula').notNull(),
ospResponsibleRif: t.text('osp_responsible_rif').notNull(), ospResponsibleRif: t.text('osp_responsible_rif'),
civilState: t.text('civil_state').notNull(), civilState: t.text('civil_state'),
ospResponsiblePhone: t.text('osp_responsible_phone').notNull(), ospResponsiblePhone: t.text('osp_responsible_phone').notNull(),
ospResponsibleEmail: t.text('osp_responsible_email').notNull(), ospResponsibleEmail: t.text('osp_responsible_email'),
familyBurden: t.integer('family_burden').notNull(), familyBurden: t.integer('family_burden'),
numberOfChildren: t.integer('number_of_children').notNull(), numberOfChildren: t.integer('number_of_children'),
generalObservations: t.text('general_observations'), generalObservations: t.text('general_observations'),
// Fotos // Fotos
photo1: t.text('photo1'), photo1: t.text('photo1'),
photo2: t.text('photo2'), photo2: t.text('photo2'),
photo3: t.text('photo3'), photo3: t.text('photo3'),
// informacion del usuario que creo y actualizo el registro // informacion del usuario que creo y actualizo el registro
createdBy: t.integer('created_by').references(() => users.id, { onDelete: 'cascade' }), createdBy: t
updatedBy: t.integer('updated_by').references(() => users.id, { onDelete: 'cascade' }), .integer('created_by')
.references(() => users.id, { onDelete: 'cascade' }),
updatedBy: t
.integer('updated_by')
.references(() => users.id, { onDelete: 'cascade' }),
...timestamps, ...timestamps,
}, },
(trainingSurveys) => ({ (trainingSurveys) => ({

View File

@@ -4,9 +4,11 @@ import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsDateString, IsDateString,
IsEmail,
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
export class CreateTrainingDto { export class CreateTrainingDto {
@@ -124,6 +126,7 @@ export class CreateTrainingDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@IsOptional()
ospResponsibleRif: string; ospResponsibleRif: string;
@ApiProperty() @ApiProperty()
@@ -131,20 +134,25 @@ export class CreateTrainingDto {
ospResponsiblePhone: string; ospResponsiblePhone: string;
@ApiProperty() @ApiProperty()
@IsString() @IsOptional()
ospResponsibleEmail: string; @ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
ospResponsibleEmail?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@IsOptional()
civilState: string; civilState: string;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@IsOptional()
@Type(() => Number) // Convierte "3" -> 3 @Type(() => Number) // Convierte "3" -> 3
familyBurden: number; familyBurden: number;
@ApiProperty() @ApiProperty()
@IsInt() @IsInt()
@IsOptional()
@Type(() => Number) @Type(() => Number)
numberOfChildren: number; numberOfChildren: number;
@@ -165,14 +173,15 @@ export class CreateTrainingDto {
@IsString() @IsString()
communeSpokespersonName: string; communeSpokespersonName: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
communeSpokespersonPhone: string; communeSpokespersonPhone: string;
@ApiProperty() @ApiProperty()
@IsOptional() @IsOptional()
communeEmail: string; @ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
communeEmail?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -195,10 +204,10 @@ export class CreateTrainingDto {
communalCouncilSpokespersonPhone: string; communalCouncilSpokespersonPhone: string;
@ApiProperty() @ApiProperty()
@IsString() @IsOptional()
communalCouncilEmail: string; @ValidateIf((o, v) => v !== '' && v !== null && v !== undefined)
@IsEmail()
communalCouncilEmail?: string;
// === 6. LISTAS (Arrays JSON) === // === 6. LISTAS (Arrays JSON) ===
// Reciben un string JSON '[{...}]' y lo convierten a Objeto JS real // Reciben un string JSON '[{...}]' y lo convierten a Objeto JS real
@@ -248,13 +257,11 @@ export class CreateTrainingDto {
}) })
productList?: any[]; productList?: any[];
//ubicacion //ubicacion
@ApiProperty() @ApiProperty()
@IsString() @IsString()
state: string; state: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
municipality: string; municipality: string;

View File

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

View File

@@ -1,12 +1,11 @@
import { HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { MinioService } from '@/common/minio/minio.service';
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm'; import { and, eq, gte, ilike, lte, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as fs from 'fs';
import * as path from 'path';
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider'; import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import * as schema from 'src/database/index'; import * as schema from 'src/database/index';
import { municipalities, parishes, states, trainingSurveys } from 'src/database/index'; import { states, trainingSurveys } from 'src/database/index';
import XlsxPopulate from 'xlsx-populate';
import { PaginationDto } from '../../common/dto/pagination.dto'; import { PaginationDto } from '../../common/dto/pagination.dto';
import { CreateTrainingDto } from './dto/create-training.dto'; import { CreateTrainingDto } from './dto/create-training.dto';
import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto'; import { TrainingStatisticsFilterDto } from './dto/training-statistics-filter.dto';
@@ -16,7 +15,8 @@ import { UpdateTrainingDto } from './dto/update-training.dto';
export class TrainingService { export class TrainingService {
constructor( constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>, @Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { } private readonly minioService: MinioService,
) {}
async findAll(paginationDto?: PaginationDto) { async findAll(paginationDto?: PaginationDto) {
const { const {
@@ -232,33 +232,33 @@ export class TrainingService {
private async saveFiles(files: Express.Multer.File[]): Promise<string[]> { private async saveFiles(files: Express.Multer.File[]): Promise<string[]> {
if (!files || files.length === 0) return []; if (!files || files.length === 0) return [];
const uploadDir = './uploads/training';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const savedPaths: string[] = []; const savedPaths: string[] = [];
for (const file of files) { for (const file of files) {
const fileName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`; const objectName = await this.minioService.upload(file, 'training');
const filePath = path.join(uploadDir, fileName); const fileUrl = this.minioService.getPublicUrl(objectName);
fs.writeFileSync(filePath, file.buffer); savedPaths.push(fileUrl);
savedPaths.push(`/assets/training/${fileName}`);
} }
return savedPaths; return savedPaths;
} }
private deleteFile(assetPath: string) { private async deleteFile(fileUrl: string) {
if (!assetPath) return; if (!fileUrl) return;
// Map /assets/training/filename.webp back to ./uploads/training/filename.webp
const relativePath = assetPath.replace('/assets/training/', '');
const fullPath = path.join('./uploads/training', relativePath);
if (fs.existsSync(fullPath)) { // Extract object name from URL
// URL format: http://endpoint:port/bucket/folder/filename
// Or it could be just the path if we decided that.
// Assuming fileUrl is the full public URL from getPublicUrl
try { try {
fs.unlinkSync(fullPath); const url = new URL(fileUrl);
} catch (err) { const pathname = url.pathname; // /bucket/folder/filename
console.error(`Error deleting file ${fullPath}:`, err); const parts = pathname.split('/');
} // parts[0] is '', parts[1] is bucket, parts[2..] is objectName
const objectName = parts.slice(2).join('/');
await this.minioService.delete(objectName);
} catch (error) {
// If it's not a valid URL, maybe it's just the object name stored from before
await this.minioService.delete(fileUrl);
} }
} }
@@ -268,11 +268,13 @@ export class TrainingService {
userId: number, userId: number,
) { ) {
// 1. Guardar fotos // 1. Guardar fotos
const photoPaths = await this.saveFiles(files); const photoPaths = await this.saveFiles(files);
// 2. Extraer solo visitDate para formatearlo. // 2. Extraer solo visitDate para formatearlo.
// Ya NO extraemos state, municipality, etc. porque no vienen en el DTO. // Ya NO extraemos state, municipality, etc. porque no vienen en el DTO.
const { visitDate, state, municipality, parish, ...rest } = createTrainingDto; const { visitDate, state, municipality, parish, ...rest } =
createTrainingDto;
const [newRecord] = await this.drizzle const [newRecord] = await this.drizzle
.insert(trainingSurveys) .insert(trainingSurveys)
@@ -305,45 +307,48 @@ export class TrainingService {
userId: number, userId: number,
) { ) {
const currentRecord = await this.findOne(id); const currentRecord = await this.findOne(id);
const photoFields = ['photo1', 'photo2', 'photo3'] as const;
const photoPaths = await this.saveFiles(files); // 1. Guardar fotos nuevas en MinIO
const newFilePaths = await this.saveFiles(files);
const updateData: any = { ...updateTrainingDto }; const updateData: any = { ...updateTrainingDto };
// Handle photo updates/removals // 2. Determinar el estado final de las fotos (diff)
const photoFields = ['photo1', 'photo2', 'photo3'] as const; // - Si el DTO tiene un valor (URL existente o ''), lo usamos.
// - Si el DTO no tiene el campo (undefined), mantenemos el de la DB.
// 1. First, handle explicit deletions (where field is '') const finalPhotos: (string | null)[] = photoFields.map((field) => {
photoFields.forEach((field) => { const dtoValue = updateData[field];
if (updateData[field] === '') { if (dtoValue !== undefined) {
const oldPath = currentRecord[field]; return dtoValue === '' ? null : dtoValue;
if (oldPath) this.deleteFile(oldPath);
updateData[field] = null;
} }
return currentRecord[field];
}); });
// 2. We need to find which slots are currently "available" (null) after deletions // 3. Asignar los nuevos paths subidos a los slots que quedaron vacíos
// and which ones have existing URLs that we want to keep. if (newFilePaths.length > 0) {
let newIdx = 0;
// Let's determine the final state of the 3 slots. for (let i = 0; i < 3 && newIdx < newFilePaths.length; i++) {
const finalPhotos: (string | null)[] = [
updateData.photo1 !== undefined ? updateData.photo1 : currentRecord.photo1,
updateData.photo2 !== undefined ? updateData.photo2 : currentRecord.photo2,
updateData.photo3 !== undefined ? updateData.photo3 : currentRecord.photo3,
];
// 3. Fill the available (null) slots with NEW photo paths
if (photoPaths.length > 0) {
let photoPathIdx = 0;
for (let i = 0; i < 3 && photoPathIdx < photoPaths.length; i++) {
if (!finalPhotos[i]) { if (!finalPhotos[i]) {
finalPhotos[i] = photoPaths[photoPathIdx]; finalPhotos[i] = newFilePaths[newIdx];
photoPathIdx++; newIdx++;
} }
} }
} }
// Assign back to updateData // 4. LIMPIEZA: Borrar de MinIO los archivos que ya no están en ningún slot
const oldPhotos = photoFields
.map((f) => currentRecord[f])
.filter((p): p is string => Boolean(p));
const newPhotosSet = new Set(finalPhotos.filter(Boolean));
for (const oldPath of oldPhotos) {
if (!newPhotosSet.has(oldPath)) {
await this.deleteFile(oldPath);
}
}
// 5. Preparar datos finales para la DB
updateData.photo1 = finalPhotos[0]; updateData.photo1 = finalPhotos[0];
updateData.photo2 = finalPhotos[1]; updateData.photo2 = finalPhotos[1];
updateData.photo3 = finalPhotos[2]; updateData.photo3 = finalPhotos[2];
@@ -368,9 +373,9 @@ export class TrainingService {
const record = await this.findOne(id); const record = await this.findOne(id);
// Delete associated files // Delete associated files
if (record.photo1) this.deleteFile(record.photo1); if (record.photo1) await this.deleteFile(record.photo1);
if (record.photo2) this.deleteFile(record.photo2); if (record.photo2) await this.deleteFile(record.photo2);
if (record.photo3) this.deleteFile(record.photo3); if (record.photo3) await this.deleteFile(record.photo3);
const [deletedRecord] = await this.drizzle const [deletedRecord] = await this.drizzle
.delete(trainingSurveys) .delete(trainingSurveys)
@@ -499,139 +504,182 @@ export class TrainingService {
// return await workbook.outputAsync(); // return await workbook.outputAsync();
// } // }
async exportTemplate(id: number) { // async exportTemplate(id: number) {
// // Validar que el registro exista
// const exist = await this.findOne(id);
// if (!exist) throw new NotFoundException(`No se encontro el registro`);
// Validar que el registro exista // // Obtener los datos del registro
const exist = await this.findOne(id); // const records = await this.drizzle
if (!exist) throw new NotFoundException(`No se encontro el registro`); // .select({
// // id: trainingSurveys.id,
// visitDate: trainingSurveys.visitDate,
// ospName: trainingSurveys.ospName,
// productiveSector: trainingSurveys.productiveSector,
// ospAddress: trainingSurveys.ospAddress,
// ospRif: trainingSurveys.ospRif,
// Obtener los datos del registro // siturCodeCommune: trainingSurveys.siturCodeCommune,
const records = await this.drizzle // communeEmail: trainingSurveys.communeEmail,
.select({ // communeRif: trainingSurveys.communeRif,
// id: trainingSurveys.id, // communeSpokespersonName: trainingSurveys.communeSpokespersonName,
visitDate: trainingSurveys.visitDate, // communeSpokespersonPhone: trainingSurveys.communeSpokespersonPhone,
ospName: trainingSurveys.ospName,
productiveSector: trainingSurveys.productiveSector,
ospAddress: trainingSurveys.ospAddress,
ospRif: trainingSurveys.ospRif,
siturCodeCommune: trainingSurveys.siturCodeCommune, // siturCodeCommunalCouncil: trainingSurveys.siturCodeCommunalCouncil,
communeEmail: trainingSurveys.communeEmail, // communalCouncilRif: trainingSurveys.communalCouncilRif,
communeRif: trainingSurveys.communeRif, // communalCouncilSpokespersonName:
communeSpokespersonName: trainingSurveys.communeSpokespersonName, // trainingSurveys.communalCouncilSpokespersonName,
communeSpokespersonPhone: trainingSurveys.communeSpokespersonPhone, // communalCouncilSpokespersonPhone:
// trainingSurveys.communalCouncilSpokespersonPhone,
siturCodeCommunalCouncil: trainingSurveys.siturCodeCommunalCouncil, // ospType: trainingSurveys.ospType,
communalCouncilRif: trainingSurveys.communalCouncilRif, // productiveActivity: trainingSurveys.productiveActivity, // Sector Productivo
communalCouncilSpokespersonName: trainingSurveys.communalCouncilSpokespersonName, // companyConstitutionYear: trainingSurveys.companyConstitutionYear,
communalCouncilSpokespersonPhone: trainingSurveys.communalCouncilSpokespersonPhone, // infrastructureMt2: trainingSurveys.infrastructureMt2,
ospType: trainingSurveys.ospType, // hasTransport: trainingSurveys.hasTransport,
productiveActivity: trainingSurveys.productiveActivity, // Sector Productivo // structureType: trainingSurveys.structureType,
companyConstitutionYear: trainingSurveys.companyConstitutionYear, // isOpenSpace: trainingSurveys.isOpenSpace,
infrastructureMt2: trainingSurveys.infrastructureMt2,
hasTransport: trainingSurveys.hasTransport, // ospResponsibleFullname: trainingSurveys.ospResponsibleFullname,
structureType: trainingSurveys.structureType, // ospResponsibleCedula: trainingSurveys.ospResponsibleCedula,
isOpenSpace: trainingSurveys.isOpenSpace, // ospResponsiblePhone: trainingSurveys.ospResponsiblePhone,
ospResponsibleFullname: trainingSurveys.ospResponsibleFullname, // productList: trainingSurveys.productList,
ospResponsibleCedula: trainingSurveys.ospResponsibleCedula, // equipmentList: trainingSurveys.equipmentList,
ospResponsiblePhone: trainingSurveys.ospResponsiblePhone, // productionList: trainingSurveys.productionList,
productList: trainingSurveys.productList, // // photo1: trainingSurveys.photo1
equipmentList: trainingSurveys.equipmentList, // })
productionList: trainingSurveys.productionList, // .from(trainingSurveys)
// .where(eq(trainingSurveys.id, id));
// // .leftJoin(states, eq(trainingSurveys.state, states.id))
// // .leftJoin(municipalities,eq(trainingSurveys.municipality, municipalities.id))
// // .leftJoin(parishes, eq(trainingSurveys.parish, parishes.id))
// photo1: trainingSurveys.photo1 // let equipmentList: any[] = Array.isArray(records[0].equipmentList)
}) // ? records[0].equipmentList
.from(trainingSurveys) // : [];
.where(eq(trainingSurveys.id, id)) // let productList: any[] = Array.isArray(records[0].productList)
// .leftJoin(states, eq(trainingSurveys.state, states.id)) // ? records[0].productList
// .leftJoin(municipalities,eq(trainingSurveys.municipality, municipalities.id)) // : [];
// .leftJoin(parishes, eq(trainingSurveys.parish, parishes.id)) // let productionList: any[] = Array.isArray(records[0].productionList)
// ? records[0].productionList
// : [];
let equipmentList: any[] = Array.isArray(records[0].equipmentList) ? records[0].equipmentList : []; // console.log('equipmentList', equipmentList);
let productList: any[] = Array.isArray(records[0].productList) ? records[0].productList : []; // console.log('productList', productList);
let productionList: any[] = Array.isArray(records[0].productionList) ? records[0].productionList : []; // console.log('productionList', productionList);
console.log('equipmentList', equipmentList); // let equipmentListArray: any[] = [];
console.log('productList', productList); // let productListArray: any[] = [];
console.log('productionList', productionList); // let productionListArray: any[] = [];
let equipmentListArray: any[] = []; // const equipmentListCount = equipmentList.length;
let productListArray: any[] = []; // for (let i = 0; i < equipmentListCount; i++) {
let productionListArray: any[] = []; // equipmentListArray.push([
// equipmentList[i].machine,
// '',
// equipmentList[i].quantity,
// ]);
// }
const equipmentListCount = equipmentList.length; // const productListCount = productList.length;
for (let i = 0; i < equipmentListCount; i++) { // for (let i = 0; i < productListCount; i++) {
equipmentListArray.push([equipmentList[i].machine, '', equipmentList[i].quantity]); // productListArray.push([
} // productList[i].productName,
// productList[i].dailyCount,
// productList[i].weeklyCount,
// productList[i].monthlyCount,
// ]);
// }
const productListCount = productList.length; // const productionListCount = productionList.length;
for (let i = 0; i < productListCount; i++) { // for (let i = 0; i < productionListCount; i++) {
productListArray.push([productList[i].productName, productList[i].dailyCount, productList[i].weeklyCount, productList[i].monthlyCount]); // productionListArray.push([
} // productionList[i].rawMaterial,
// '',
// productionList[i].quantity,
// ]);
// }
const productionListCount = productionList.length; // // Ruta de la plantilla
for (let i = 0; i < productionListCount; i++) { // const templatePath = path.join(
productionListArray.push([productionList[i].rawMaterial, '', productionList[i].quantity]); // __dirname,
} // 'export_template',
// 'excel.osp.xlsx',
// );
// Ruta de la plantilla // // Cargar la plantilla
const templatePath = path.join( // const book = await XlsxPopulate.fromFileAsync(templatePath);
__dirname,
'export_template',
'excel.osp.xlsx',
);
// Cargar la plantilla // const isoString = records[0].visitDate;
const book = await XlsxPopulate.fromFileAsync(templatePath); // const dateObj = new Date(isoString);
// const fechaFormateada = dateObj.toLocaleDateString('es-ES');
// const horaFormateada = dateObj.toLocaleTimeString('es-ES', {
// hour: '2-digit',
// minute: '2-digit',
// });
const isoString = records[0].visitDate; // // Llenar los datos
const dateObj = new Date(isoString); // book.sheet(0).cell('A6').value(records[0].productiveSector);
const fechaFormateada = dateObj.toLocaleDateString('es-ES'); // book.sheet(0).cell('D6').value(records[0].ospName);
const horaFormateada = dateObj.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); // book.sheet(0).cell('L5').value(fechaFormateada);
// book.sheet(0).cell('L6').value(horaFormateada);
// book.sheet(0).cell('B10').value(records[0].ospAddress);
// book.sheet(0).cell('C11').value(records[0].communeEmail);
// book.sheet(0).cell('C12').value(records[0].communeSpokespersonName);
// book.sheet(0).cell('G11').value(records[0].communeRif);
// book.sheet(0).cell('G12').value(records[0].communeSpokespersonPhone);
// book.sheet(0).cell('C13').value(records[0].siturCodeCommune);
// book.sheet(0).cell('G13').value(records[0].siturCodeCommunalCouncil);
// book.sheet(0).cell('G14').value(records[0].communalCouncilRif);
// book.sheet(0).cell('C15').value(records[0].communalCouncilSpokespersonName);
// book
// .sheet(0)
// .cell('G15')
// .value(records[0].communalCouncilSpokespersonPhone);
// book.sheet(0).cell('C16').value(records[0].ospType);
// book.sheet(0).cell('C17').value(records[0].ospName);
// book.sheet(0).cell('C18').value(records[0].productiveActivity);
// book.sheet(0).cell('C19').value('Proveedores');
// book.sheet(0).cell('C20').value(records[0].companyConstitutionYear);
// book.sheet(0).cell('C21').value(records[0].infrastructureMt2);
// book.sheet(0).cell('G17').value(records[0].ospRif);
// Llenar los datos // book
book.sheet(0).cell('A6').value(records[0].productiveSector); // .sheet(0)
book.sheet(0).cell('D6').value(records[0].ospName); // .cell(records[0].hasTransport === true ? 'J19' : 'L19')
book.sheet(0).cell('L5').value(fechaFormateada); // .value('X');
book.sheet(0).cell('L6').value(horaFormateada); // book
book.sheet(0).cell('B10').value(records[0].ospAddress); // .sheet(0)
book.sheet(0).cell('C11').value(records[0].communeEmail); // .cell(records[0].structureType === 'CASA' ? 'J20' : 'L20')
book.sheet(0).cell('C12').value(records[0].communeSpokespersonName); // .value('X');
book.sheet(0).cell('G11').value(records[0].communeRif); // book
book.sheet(0).cell('G12').value(records[0].communeSpokespersonPhone); // .sheet(0)
book.sheet(0).cell('C13').value(records[0].siturCodeCommune); // .cell(records[0].isOpenSpace === true ? 'J21' : 'L21')
book.sheet(0).cell('G13').value(records[0].siturCodeCommunalCouncil); // .value('X');
book.sheet(0).cell('G14').value(records[0].communalCouncilRif);
book.sheet(0).cell('C15').value(records[0].communalCouncilSpokespersonName);
book.sheet(0).cell('G15').value(records[0].communalCouncilSpokespersonPhone);
book.sheet(0).cell('C16').value(records[0].ospType);
book.sheet(0).cell('C17').value(records[0].ospName);
book.sheet(0).cell('C18').value(records[0].productiveActivity);
book.sheet(0).cell('C19').value('Proveedores');
book.sheet(0).cell('C20').value(records[0].companyConstitutionYear);
book.sheet(0).cell('C21').value(records[0].infrastructureMt2);
book.sheet(0).cell('G17').value(records[0].ospRif);
book.sheet(0).cell(records[0].hasTransport === true ? 'J19' : 'L19').value('X'); // book.sheet(0).cell('A24').value(records[0].ospResponsibleFullname);
book.sheet(0).cell(records[0].structureType === 'CASA' ? 'J20' : 'L20').value('X'); // book.sheet(0).cell('C24').value(records[0].ospResponsibleCedula);
book.sheet(0).cell(records[0].isOpenSpace === true ? 'J21' : 'L21').value('X'); // book.sheet(0).cell('E24').value(records[0].ospResponsiblePhone);
book.sheet(0).cell('A24').value(records[0].ospResponsibleFullname); // book.sheet(0).cell('J24').value('N Femenino');
book.sheet(0).cell('C24').value(records[0].ospResponsibleCedula); // book.sheet(0).cell('L24').value('N Masculino');
book.sheet(0).cell('E24').value(records[0].ospResponsiblePhone);
// book
// .sheet(0)
// .range(`A28:C${equipmentListCount + 28}`)
// .value(equipmentListArray);
// book
// .sheet(0)
// .range(`E28:G${productionListCount + 28}`)
// .value(productionListArray);
// book
// .sheet(0)
// .range(`I28:L${productListCount + 28}`)
// .value(productListArray);
book.sheet(0).cell('J24').value('N Femenino'); // return book.outputAsync();
book.sheet(0).cell('L24').value('N Masculino'); // }
book.sheet(0).range(`A28:C${equipmentListCount + 28}`).value(equipmentListArray);
book.sheet(0).range(`E28:G${productionListCount + 28}`).value(productionListArray);
book.sheet(0).range(`I28:L${productListCount + 28}`).value(productListArray);
return book.outputAsync();
}
} }

View File

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

View File

@@ -162,14 +162,16 @@ export function CreateTrainingForm({
}); });
// 1. Extrae errors de formState // 1. Extrae errors de formState
const { formState: { errors } } = form; const {
formState: { errors },
} = form;
// 2. Crea un efecto para monitorearlos // 2. Crea un efecto para monitorearlos
useEffect(() => { useEffect(() => {
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
console.log("Campos con errores:", errors); console.log('Campos con errores:', errors);
} }
}, [errors]); }, [errors]);
// Cascading Select Logic // Cascading Select Logic
const ecoSector = useWatch({ control: form.control, name: 'ecoSector' }); const ecoSector = useWatch({ control: form.control, name: 'ecoSector' });
@@ -219,8 +221,7 @@ useEffect(() => {
{ id: 0, name: 'Sin estados' }, { id: 0, name: 'Sin estados' },
]; ];
const coorMunicipalityOptions = const coorMunicipalityOptions = dataCoorMunicipality?.data?.length
dataCoorMunicipality?.data?.length
? dataCoorMunicipality.data ? dataCoorMunicipality.data
: [{ id: 0, stateId: 0, name: 'Sin Municipios' }]; : [{ id: 0, stateId: 0, name: 'Sin Municipios' }];
@@ -264,8 +265,6 @@ useEffect(() => {
}, [defaultValues]); }, [defaultValues]);
const onSubmit = async (formData: TrainingSchema) => { const onSubmit = async (formData: TrainingSchema) => {
const data = new FormData(); const data = new FormData();
// 1. Definimos las claves que NO queremos enviar en el bucle general // 1. Definimos las claves que NO queremos enviar en el bucle general
@@ -279,12 +278,13 @@ useEffect(() => {
]; ];
Object.entries(formData).forEach(([key, value]) => { Object.entries(formData).forEach(([key, value]) => {
// 2. Condición actualizada: Si la key está en la lista de excluidos, la saltamos // 2. Condición actualizada: Si la key está en la lista de excluidos, o es un valor vacío (null/undefined), lo saltamos.
// Permitimos cadenas vacías ('') para indicar al backend que se debe limpiar el campo (ej: borrar foto).
if (excludedKeys.includes(key) || value === undefined || value === null) { if (excludedKeys.includes(key) || value === undefined || value === null) {
return; return;
} }
// 3. Lógica de conversión (Igual que tenías) // 3. Lógica de conversión
if ( if (
Array.isArray(value) || Array.isArray(value) ||
(typeof value === 'object' && !(value instanceof Date)) (typeof value === 'object' && !(value instanceof Date))
@@ -393,10 +393,13 @@ useEffect(() => {
<FormLabel>Teléfono</FormLabel> <FormLabel>Teléfono</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number"
{...field} {...field}
placeholder="Ej. 04121234567" placeholder="Ej. 04121234567"
value={field.value ?? ''} value={field.value ?? ''}
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
field.onChange(val.slice(0, 11));
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -514,7 +517,7 @@ useEffect(() => {
</FormLabel> </FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={field.value ?? undefined}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@@ -550,7 +553,7 @@ useEffect(() => {
form.setValue('mainProductiveActivity', ''); form.setValue('mainProductiveActivity', '');
form.setValue('productiveActivity', ''); form.setValue('productiveActivity', '');
}} }}
defaultValue={field.value} defaultValue={field.value ?? undefined}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@@ -585,7 +588,7 @@ useEffect(() => {
form.setValue('mainProductiveActivity', ''); form.setValue('mainProductiveActivity', '');
form.setValue('productiveActivity', ''); form.setValue('productiveActivity', '');
}} }}
defaultValue={field.value} defaultValue={field.value ?? undefined}
disabled={!ecoSector} disabled={!ecoSector}
> >
<FormControl> <FormControl>
@@ -620,7 +623,7 @@ useEffect(() => {
form.setValue('mainProductiveActivity', ''); form.setValue('mainProductiveActivity', '');
form.setValue('productiveActivity', ''); form.setValue('productiveActivity', '');
}} }}
defaultValue={field.value} defaultValue={field.value ?? undefined}
disabled={!productiveSector} disabled={!productiveSector}
> >
<FormControl> <FormControl>
@@ -654,7 +657,7 @@ useEffect(() => {
field.onChange(val); field.onChange(val);
form.setValue('productiveActivity', ''); form.setValue('productiveActivity', '');
}} }}
defaultValue={field.value} defaultValue={field.value ?? undefined}
disabled={!centralProductiveActivity} disabled={!centralProductiveActivity}
> >
<FormControl> <FormControl>
@@ -685,7 +688,7 @@ useEffect(() => {
</FormLabel> </FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={field.value ?? undefined}
disabled={!mainProductiveActivity} disabled={!mainProductiveActivity}
> >
<FormControl> <FormControl>
@@ -715,7 +718,11 @@ useEffect(() => {
RIF de la organización (opcional) RIF de la organización (opcional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="J-12345678-9" /> <Input
{...field}
value={field.value ?? ''}
placeholder="J-12345678-9"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -731,7 +738,7 @@ useEffect(() => {
Nombre de la organización (opcional) Nombre de la organización (opcional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -747,7 +754,11 @@ useEffect(() => {
Año de constitución Año de constitución
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input type="number" {...field} /> <Input
type="number"
{...field}
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -764,7 +775,7 @@ useEffect(() => {
</FormLabel> </FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={field.value ?? undefined}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -793,7 +804,11 @@ useEffect(() => {
infraestrutura (MT2) infraestrutura (MT2)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="e.g. 500" /> <Input
{...field}
value={field.value ?? ''}
placeholder="e.g. 500"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -837,7 +852,7 @@ useEffect(() => {
</FormLabel> </FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={field.value ?? undefined}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -846,9 +861,9 @@ useEffect(() => {
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="CASA">CASA</SelectItem> <SelectItem value="CASA">CASA</SelectItem>
<SelectItem value="GALPON">GALPON</SelectItem> <SelectItem value="GALPÓN">GALPÓN</SelectItem>
<SelectItem value="LOCAL">LOCAL</SelectItem> <SelectItem value="LOCAL">LOCAL</SelectItem>
<SelectItem value="ALMACEN">ALMACEN</SelectItem> <SelectItem value="ALMACÉN">ALMACÉN</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -893,7 +908,7 @@ useEffect(() => {
Razones de paralización (opcional) Razones de paralización (opcional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Textarea {...field} /> <Textarea {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -952,6 +967,7 @@ useEffect(() => {
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
value={field.value ?? ''}
placeholder="https://maps.google.com/..." placeholder="https://maps.google.com/..."
/> />
</FormControl> </FormControl>
@@ -973,7 +989,7 @@ useEffect(() => {
Nombre de la Comuna Nombre de la Comuna
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -989,7 +1005,7 @@ useEffect(() => {
Código SITUR de la Comuna Código SITUR de la Comuna
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1005,7 +1021,7 @@ useEffect(() => {
Rif de la Comuna Rif de la Comuna
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1021,14 +1037,13 @@ useEffect(() => {
Nombre del Vocero o Vocera Nombre del Vocero o Vocera
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="communeSpokespersonPhone" name="communeSpokespersonPhone"
@@ -1038,7 +1053,15 @@ useEffect(() => {
Número de Teléfono del Vocero Número de Teléfono del Vocero
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input
{...field}
value={field.value ?? ''}
placeholder="Ej. 04121234567"
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
field.onChange(val.slice(0, 11));
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1054,7 +1077,11 @@ useEffect(() => {
Correo Electrónico de la Comuna (Opcional) Correo Electrónico de la Comuna (Opcional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input type="email" {...field} /> <Input
type="email"
{...field}
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1092,7 +1119,7 @@ useEffect(() => {
Código SITUR del Consejo Comunal Código SITUR del Consejo Comunal
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1108,7 +1135,7 @@ useEffect(() => {
Rif del Consejo Comunal Rif del Consejo Comunal
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1124,7 +1151,7 @@ useEffect(() => {
Nombre del Vocero o Vocera Nombre del Vocero o Vocera
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1140,7 +1167,15 @@ useEffect(() => {
Número de Teléfono del Vocero Número de Teléfono del Vocero
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input
{...field}
value={field.value ?? ''}
placeholder="Ej. 04121234567"
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
field.onChange(val.slice(0, 11));
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1156,7 +1191,11 @@ useEffect(() => {
Correo Electrónico del Consejo Comunal (Opcional) Correo Electrónico del Consejo Comunal (Opcional)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input type="email" {...field} /> <Input
type="email"
{...field}
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1204,9 +1243,9 @@ useEffect(() => {
name="ospResponsibleRif" name="ospResponsibleRif"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>RIF</FormLabel> <FormLabel>RIF (Opcional)</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1218,10 +1257,10 @@ useEffect(() => {
name="civilState" name="civilState"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Estado Civil</FormLabel> <FormLabel>Estado Civil (Opcional)</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={field.value ?? undefined}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@@ -1248,7 +1287,15 @@ useEffect(() => {
<FormItem> <FormItem>
<FormLabel>Teléfono</FormLabel> <FormLabel>Teléfono</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input
{...field}
value={field.value ?? ''}
placeholder="Ej. 04121234567"
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
field.onChange(val.slice(0, 11));
}}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1260,9 +1307,13 @@ useEffect(() => {
name="ospResponsibleEmail" name="ospResponsibleEmail"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Correo Electrónico</FormLabel> <FormLabel>Correo Electrónico (Opcional)</FormLabel>
<FormControl> <FormControl>
<Input type="email" {...field} /> <Input
type="email"
{...field}
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1274,9 +1325,13 @@ useEffect(() => {
name="familyBurden" name="familyBurden"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Carga Familiar</FormLabel> <FormLabel>Carga Familiar (Opcional)</FormLabel>
<FormControl> <FormControl>
<Input type="number" {...field} /> <Input
type="number"
{...field}
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1288,9 +1343,13 @@ useEffect(() => {
name="numberOfChildren" name="numberOfChildren"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Número de Hijos</FormLabel> <FormLabel>Número de Hijos (Opcional)</FormLabel>
<FormControl> <FormControl>
<Input type="number" {...field} /> <Input
type="number"
{...field}
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1312,7 +1371,7 @@ useEffect(() => {
<FormItem> <FormItem>
<FormLabel>Observaciones Generales (Opcional)</FormLabel> <FormLabel>Observaciones Generales (Opcional)</FormLabel>
<FormControl> <FormControl>
<Textarea {...field} /> <Textarea {...field} value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1344,7 +1403,7 @@ useEffect(() => {
className="relative aspect-square rounded-md overflow-hidden bg-muted group" className="relative aspect-square rounded-md overflow-hidden bg-muted group"
> >
<img <img
src={`${process.env.NEXT_PUBLIC_API_URL}${photoUrl}`} src={`${photoUrl}`}
alt={`Existing ${idx + 1}`} alt={`Existing ${idx + 1}`}
className="object-cover w-full h-full" className="object-cover w-full h-full"
/> />
@@ -1397,7 +1456,9 @@ useEffect(() => {
newFiles.length + selectedFiles.length + existingCount > newFiles.length + selectedFiles.length + existingCount >
3 3
) { ) {
toast.error(`Máximo 3 imágenes en total. Ya tienes ${existingCount} subidas y ${selectedFiles.length} seleccionadas para subir.`) toast.error(
`Máximo 3 imágenes en total. Ya tienes ${existingCount} subidas y ${selectedFiles.length} seleccionadas para subir.`,
);
e.target.value = ''; e.target.value = '';
return; return;
} }

View File

@@ -31,39 +31,19 @@ import {
SelectValue, SelectValue,
} from '@repo/shadcn/select'; } from '@repo/shadcn/select';
import { SelectSearchable } from '@repo/shadcn/select-searchable'; import { SelectSearchable } from '@repo/shadcn/select-searchable';
import { Switch } from '@repo/shadcn/switch';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form'; import { useFieldArray, useFormContext } from 'react-hook-form';
import { TrainingSchema } from '../schemas/training';
const UNIT_OPTIONS = [ const UNIT_OPTIONS = ['KG', 'TON', 'UNID', 'LT', 'MTS', 'QQ', 'HM2', 'SACOS'];
'KG',
'TON',
'UNID',
'LT',
'MTS',
'QQ',
'HM2',
'SACOS',
];
// 1. Definimos la estructura de los datos para que TypeScript no se queje // 1. Definimos la estructura de los datos para que TypeScript no se queje
interface ProductItem { // ProductItem y ProductFormValues locales eliminados en favor de TrainingSchema
productName: string;
description: string;
dailyCount: string;
weeklyCount: string;
monthlyCount: string;
// ... resto de propiedades opcionales si las necesitas tipar estrictamente
[key: string]: any;
}
interface ProductFormValues {
productList: ProductItem[];
}
export function ProductActivityList() { export function ProductActivityList() {
// 2. Pasamos el tipo genérico a useFormContext const { control, register } = useFormContext<TrainingSchema>();
const { control, register } = useFormContext<ProductFormValues>();
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
@@ -74,7 +54,6 @@ export function ProductActivityList() {
// Modal Form State // Modal Form State
const [newItem, setNewItem] = useState<any>({ const [newItem, setNewItem] = useState<any>({
productName: '',
description: '', description: '',
dailyCount: '', dailyCount: '',
weeklyCount: '', weeklyCount: '',
@@ -102,6 +81,7 @@ export function ProductActivityList() {
// Workforce // Workforce
womenCount: '', womenCount: '',
menCount: '', menCount: '',
isExporting: false,
}); });
// Location logic for Internal Validation // Location logic for Internal Validation
@@ -121,10 +101,9 @@ export function ProductActivityList() {
const isVenezuela = newItem.externalCountry === 'Venezuela'; const isVenezuela = newItem.externalCountry === 'Venezuela';
const handleAdd = () => { const handleAdd = () => {
if (newItem.productName) { if (newItem.description) {
append(newItem); append(newItem);
setNewItem({ setNewItem({
productName: '',
description: '', description: '',
dailyCount: '', dailyCount: '',
weeklyCount: '', weeklyCount: '',
@@ -146,6 +125,7 @@ export function ProductActivityList() {
externalUnit: '', externalUnit: '',
womenCount: '', womenCount: '',
menCount: '', menCount: '',
isExporting: false,
}); });
setInternalStateId(0); setInternalStateId(0);
setInternalMuniId(0); setInternalMuniId(0);
@@ -171,7 +151,7 @@ export function ProductActivityList() {
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto"> <DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Detalles de Actividad Productiva</DialogTitle> <DialogTitle>Producto Terminado</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Datos de actividad productiva Datos de actividad productiva
@@ -179,15 +159,6 @@ export function ProductActivityList() {
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{/* Basic Info */} {/* Basic Info */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Producto Terminado</Label>
<Input
value={newItem.productName}
onChange={(e) =>
setNewItem({ ...newItem, productName: e.target.value })
}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Descripción</Label> <Label>Descripción</Label>
<Input <Input
@@ -250,7 +221,24 @@ export function ProductActivityList() {
</div> </div>
<hr /> <hr />
<h4 className="font-semibold">Exportación</h4> <div className="flex items-center space-x-2">
<Switch
id="export-toggle"
checked={newItem.isExporting}
onCheckedChange={(val: boolean) =>
setNewItem({ ...newItem, isExporting: val })
}
/>
<Label htmlFor="export-toggle">
¿El producto es para exportación?
</Label>
</div>
{newItem.isExporting && (
<>
<h4 className="font-semibold text-sm">
Datos de Exportación
</h4>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>País</Label> <Label>País</Label>
@@ -264,7 +252,6 @@ export function ProductActivityList() {
<SelectValue placeholder="Seleccione País" /> <SelectValue placeholder="Seleccione País" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* 3. CORRECCIÓN DEL MAPEO DE PAÍSES Y KEYS */}
{COUNTRY_OPTIONS.map((country: string) => ( {COUNTRY_OPTIONS.map((country: string) => (
<SelectItem key={country} value={country}> <SelectItem key={country} value={country}>
{country} {country}
@@ -279,7 +266,10 @@ export function ProductActivityList() {
<Input <Input
value={newItem.externalCity} value={newItem.externalCity}
onChange={(e) => onChange={(e) =>
setNewItem({ ...newItem, externalCity: e.target.value }) setNewItem({
...newItem,
externalCity: e.target.value,
})
} }
/> />
</div> </div>
@@ -313,7 +303,10 @@ export function ProductActivityList() {
onValueChange={(val) => { onValueChange={(val) => {
const id = Number(val); const id = Number(val);
setExternalMuniId(id); setExternalMuniId(id);
setNewItem({ ...newItem, externalMunicipality: id }); setNewItem({
...newItem,
externalMunicipality: id,
});
}} }}
placeholder="Municipio" placeholder="Municipio"
disabled={!externalStateId} disabled={!externalStateId}
@@ -327,7 +320,10 @@ export function ProductActivityList() {
label: s.name, label: s.name,
}))} }))}
onValueChange={(val) => onValueChange={(val) =>
setNewItem({ ...newItem, externalParish: Number(val) }) setNewItem({
...newItem,
externalParish: Number(val),
})
} }
placeholder="Parroquia" placeholder="Parroquia"
disabled={!externalMuniId} disabled={!externalMuniId}
@@ -383,6 +379,8 @@ export function ProductActivityList() {
</div> </div>
</div> </div>
</div> </div>
</>
)}
<hr /> <hr />
<h4 className="font-semibold">Mano de Obra</h4> <h4 className="font-semibold">Mano de Obra</h4>
@@ -428,9 +426,8 @@ export function ProductActivityList() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Producto</TableHead> <TableHead>Producto/Descripción</TableHead>
<TableHead>Descripción</TableHead> <TableHead>Producción Mensual</TableHead>
<TableHead>Mensual</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -440,13 +437,11 @@ export function ProductActivityList() {
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`productList.${index}.productName`)} {...register(`productList.${index}.description`)}
// field.productName ahora es válido gracias a la interface value={field.description}
value={field.productName}
/> />
{field.productName} {field.description}
</TableCell> </TableCell>
<TableCell>{field.description}</TableCell>
<TableCell>{field.monthlyCount}</TableCell> <TableCell>{field.monthlyCount}</TableCell>
<TableCell> <TableCell>
<Button <Button

View File

@@ -27,36 +27,29 @@ import {
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form'; import { useFieldArray, useFormContext } from 'react-hook-form';
import { TrainingSchema } from '../schemas/training';
const UNIT_OPTIONS = [ const UNIT_OPTIONS = ['KG', 'TON', 'UNID', 'LT', 'MTS', 'QQ', 'HM2', 'SACOS'];
'KG',
'TON',
'UNID',
'LT',
'MTS',
'QQ',
'HM2',
'SACOS',
];
export function ProductionList() { export function ProductionList() {
const { control, register } = useFormContext(); const { control, register } = useFormContext<TrainingSchema>();
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
name: 'productionList', name: 'productionList',
}); });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [newItem, setNewItem] = useState({ const [newItem, setNewItem] = useState({
rawMaterial: '',
supplyType: '', supplyType: '',
quantity: '', quantity: '',
unit: '', unit: '',
}); });
const handleAdd = () => { const handleAdd = (e: React.MouseEvent) => {
if (newItem.rawMaterial && newItem.quantity) { e.preventDefault();
e.stopPropagation();
if (newItem.supplyType && newItem.quantity && newItem.unit) {
append({ ...newItem, quantity: Number(newItem.quantity) }); append({ ...newItem, quantity: Number(newItem.quantity) });
setNewItem({ rawMaterial: '', supplyType: '', quantity: '', unit: '' }); setNewItem({ supplyType: '', quantity: '', unit: '' });
setIsOpen(false); setIsOpen(false);
} }
}; };
@@ -69,24 +62,14 @@ export function ProductionList() {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">Agregar Producción</Button> <Button variant="outline">Agregar Producción</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle>Agregar Datos de Producción</DialogTitle> <DialogTitle>Materia prima requerida (mensual)</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Datos de producción Datos de producción
</DialogDescription> </DialogDescription>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Materia prima requerida (mensual)</Label>
<Input
value={newItem.rawMaterial}
onChange={(e) =>
setNewItem({ ...newItem, rawMaterial: e.target.value })
}
placeholder="Descripción de materia prima"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Tipo de Insumo/Rubro</Label> <Label>Tipo de Insumo/Rubro</Label>
<Input <Input
@@ -132,12 +115,23 @@ export function ProductionList() {
<Button <Button
variant="outline" variant="outline"
type="button" type="button"
onClick={() => setIsOpen(false)} onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleAdd}>Guardar</Button> <Button
type="button"
onClick={handleAdd}
disabled={
!newItem.supplyType || !newItem.quantity || !newItem.unit
}
>
Guardar
</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -148,7 +142,6 @@ export function ProductionList() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Materia Prima</TableHead>
<TableHead>Tipo Insumo</TableHead> <TableHead>Tipo Insumo</TableHead>
<TableHead>Cantidad (Mensual)</TableHead> <TableHead>Cantidad (Mensual)</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
@@ -157,39 +150,36 @@ export function ProductionList() {
<TableBody> <TableBody>
{fields.map((field, index) => ( {fields.map((field, index) => (
<TableRow key={field.id}> <TableRow key={field.id}>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.rawMaterial`)}
/>
{/* @ts-ignore */}
{field.rawMaterial}
</TableCell>
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`productionList.${index}.supplyType`)} {...register(`productionList.${index}.supplyType`)}
defaultValue={field.supplyType}
/> />
{/* @ts-ignore */}
{field.supplyType} {field.supplyType}
</TableCell> </TableCell>
<TableCell> <TableCell>
<input <input
type="hidden" type="hidden"
{...register(`productionList.${index}.quantity`)} {...register(`productionList.${index}.quantity`)}
defaultValue={field.quantity}
/> />
<input <input
type="hidden" type="hidden"
{...register(`productionList.${index}.unit`)} {...register(`productionList.${index}.unit`)}
defaultValue={field.unit}
/> />
{/* @ts-ignore */}
{field.quantity} {field.unit} {field.quantity} {field.unit}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => remove(index)} type="button"
onClick={(e) => {
e.preventDefault();
remove(index);
}}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>

View File

@@ -1,5 +1,10 @@
'use client'; 'use client';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
import { Badge } from '@repo/shadcn/badge'; import { Badge } from '@repo/shadcn/badge';
import { Button } from '@repo/shadcn/button'; import { Button } from '@repo/shadcn/button';
import { import {
@@ -28,11 +33,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { TrainingSchema } from '../schemas/training'; import { TrainingSchema } from '../schemas/training';
import {
useMunicipalityQuery,
useParishQuery,
useStateQuery,
} from '@/feactures/location/hooks/use-query-location';
interface TrainingViewModalProps { interface TrainingViewModalProps {
data: TrainingSchema | null; data: TrainingSchema | null;
@@ -53,7 +53,9 @@ export function TrainingViewModal({
if (!data) return null; if (!data) return null;
const stateName = statesData?.data?.find((s: any) => s.id === data.state)?.name; const stateName = statesData?.data?.find(
(s: any) => s.id === data.state,
)?.name;
const municipalityName = municipalitiesData?.data?.find( const municipalityName = municipalitiesData?.data?.find(
(m: any) => m.id === data.municipality, (m: any) => m.id === data.municipality,
)?.name; )?.name;
@@ -94,7 +96,7 @@ export function TrainingViewModal({
</Card> </Card>
); );
const BooleanBadge = ({ value }: { value?: boolean }) => ( const BooleanBadge = ({ value }: { value?: boolean | null }) => (
<Badge variant={value ? 'default' : 'secondary'}> <Badge variant={value ? 'default' : 'secondary'}>
{value ? 'Sí' : 'No'} {value ? 'Sí' : 'No'}
</Badge> </Badge>
@@ -273,7 +275,10 @@ export function TrainingViewModal({
<span className="text-xs font-bold text-muted-foreground block mb-1"> <span className="text-xs font-bold text-muted-foreground block mb-1">
DISTRIBUCIÓN INTERNA DISTRIBUCIÓN INTERNA
</span> </span>
<p>Cant: {prod.internalQuantity} {prod.internalUnit}</p> <p>
Cant: {prod.internalQuantity}{' '}
{prod.internalUnit}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{prod.internalDescription} {prod.internalDescription}
</p> </p>
@@ -284,7 +289,10 @@ export function TrainingViewModal({
<span className="text-xs font-bold text-muted-foreground block mb-1"> <span className="text-xs font-bold text-muted-foreground block mb-1">
EXPORTACIÓN ({prod.externalCountry}) EXPORTACIÓN ({prod.externalCountry})
</span> </span>
<p>Cant: {prod.externalQuantity} {prod.externalUnit}</p> <p>
Cant: {prod.externalQuantity}{' '}
{prod.externalUnit}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{prod.externalDescription} {prod.externalDescription}
</p> </p>
@@ -360,7 +368,9 @@ export function TrainingViewModal({
{mat.supplyType} {mat.supplyType}
</p> </p>
</div> </div>
<Badge variant="secondary">Cant: {mat.quantity} {mat.unit}</Badge> <Badge variant="secondary">
Cant: {mat.quantity} {mat.unit}
</Badge>
</div> </div>
))} ))}
{(!data.productionList || {(!data.productionList ||
@@ -463,7 +473,7 @@ export function TrainingViewModal({
onClick={() => setSelectedImage(photo)} onClick={() => setSelectedImage(photo)}
> >
<img <img
src={`${process.env.NEXT_PUBLIC_API_URL}${photo}`} src={`${photo}`}
alt={`Evidencia ${idx + 1}`} alt={`Evidencia ${idx + 1}`}
className="object-cover w-full h-full" className="object-cover w-full h-full"
/> />
@@ -513,7 +523,7 @@ export function TrainingViewModal({
</Button> </Button>
{selectedImage && ( {selectedImage && (
<img <img
src={`${process.env.NEXT_PUBLIC_API_URL}${selectedImage}`} src={`${selectedImage}`}
alt="Vista ampliada" alt="Vista ampliada"
className="max-w-full max-h-[90vh] object-contain rounded-md" className="max-w-full max-h-[90vh] object-contain rounded-md"
/> />

View File

@@ -3,42 +3,40 @@ import { z } from 'zod';
// 1. Definimos el esquema de un item individual de la lista de productos // 1. Definimos el esquema de un item individual de la lista de productos
// Basado en los campos que usaste en ProductActivityList // Basado en los campos que usaste en ProductActivityList
const productItemSchema = z.object({ const productItemSchema = z.object({
productName: z.string(), description: z.string().optional().nullable(),
description: z.string().optional(), dailyCount: z.coerce.string().or(z.number()).optional().nullable(),
dailyCount: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs weeklyCount: z.coerce.string().or(z.number()).optional().nullable(),
weeklyCount: z.coerce.string().or(z.number()).optional(), monthlyCount: z.coerce.string().or(z.number()).optional().nullable(),
monthlyCount: z.coerce.string().or(z.number()).optional(),
// Distribución Interna // Distribución Interna
internalDistributionZone: z.string().optional(), internalDistributionZone: z.string().optional().nullable(),
internalQuantity: z.coerce.string().or(z.number()).optional(), internalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
internalUnit: z.string().optional(), internalUnit: z.string().optional().nullable(),
// Distribución Externa // Distribución Externa
externalCountry: z.string().optional(), externalCountry: z.string().optional().nullable(),
externalState: z.number().optional().nullable(), externalState: z.number().optional().nullable(),
externalMunicipality: z.number().optional().nullable(), externalMunicipality: z.number().optional().nullable(),
externalParish: z.number().optional().nullable(), externalParish: z.number().optional().nullable(),
externalCity: z.string().optional(), externalCity: z.string().optional().nullable(),
externalDescription: z.string().optional(), externalDescription: z.string().optional().nullable(),
externalQuantity: z.coerce.string().or(z.number()).optional(), externalQuantity: z.coerce.string().or(z.number()).optional().nullable(),
externalUnit: z.string().optional(), externalUnit: z.string().optional().nullable(),
// Mano de obra // Mano de obra
womenCount: z.coerce.string().or(z.number()).optional(), womenCount: z.coerce.string().or(z.number()).optional().nullable(),
menCount: z.coerce.string().or(z.number()).optional(), menCount: z.coerce.string().or(z.number()).optional().nullable(),
}); });
const productionItemSchema = z.object({ const productionItemSchema = z.object({
rawMaterial: z.string(), supplyType: z.string().optional().nullable(),
supplyType: z.string().optional(), quantity: z.coerce.string().or(z.number()).optional().nullable(),
quantity: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs unit: z.string().min(1, { message: 'Unidad es requerida' }).nullable(),
unit: z.string().optional(),
}); });
const equipmentItemSchema = z.object({ const equipmentItemSchema = z.object({
machine: z.string(), machine: z.string().nullable(),
quantity: z.coerce.string().or(z.number()).optional(), // Aceptamos string o number por los inputs quantity: z.coerce.string().or(z.number()).optional().nullable(),
}); });
export const trainingSchema = z.object({ export const trainingSchema = z.object({
@@ -46,38 +44,47 @@ export const trainingSchema = z.object({
id: z.number().optional(), id: z.number().optional(),
firstname: z.string().min(1, { message: 'Nombre es requerido' }), firstname: z.string().min(1, { message: 'Nombre es requerido' }),
lastname: z.string().min(1, { message: 'Apellido es requerido' }), lastname: z.string().min(1, { message: 'Apellido es requerido' }),
coorPhone: z.string().optional().nullable(), coorPhone: z
.string()
.optional()
.nullable()
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
visitDate: z visitDate: z
.string() .string()
.min(1, { message: 'Fecha y hora de visita es requerida' }), .min(1, { message: 'Fecha y hora de visita es requerida' }),
//Datos de la organización socioproductiva (OSP) //Datos de la organización socioproductiva (OSP)
ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }), ospType: z.string().min(1, { message: 'Tipo de OSP es requerido' }),
ecoSector: z.string().optional().or(z.literal('')), ecoSector: z.string().optional().or(z.literal('')).nullable(),
productiveSector: z.string().optional().or(z.literal('')), productiveSector: z.string().optional().or(z.literal('')).nullable(),
centralProductiveActivity: z.string().optional().or(z.literal('')), centralProductiveActivity: z.string().optional().or(z.literal('')).nullable(),
mainProductiveActivity: z.string().optional().or(z.literal('')), mainProductiveActivity: z.string().optional().or(z.literal('')).nullable(),
productiveActivity: z productiveActivity: z
.string() .string()
.min(1, { message: 'Actividad productiva es requerida' }), .min(1, { message: 'Actividad productiva es requerida' }),
ospRif: z.string().optional().or(z.literal('')), ospRif: z.string().optional().or(z.literal('')).nullable(),
ospName: z.string().optional().or(z.literal('')), ospName: z.string().optional().or(z.literal('')).nullable(),
companyConstitutionYear: z.coerce companyConstitutionYear: z.coerce
.number() .number()
.min(1900, { message: 'Año inválido' }), .min(1900, { message: 'Año inválido' })
.nullable(),
currentStatus: z currentStatus: z
.string() .string()
.min(1, { message: 'Estatus actual es requerido' }) .min(1, { message: 'Estatus actual es requerido' })
.default('ACTIVA'), .default('ACTIVA'),
infrastructureMt2: z.string().optional().or(z.literal('')), infrastructureMt2: z.string().optional().or(z.literal('')).nullable(),
hasTransport: z hasTransport: z
.preprocess((val) => val === 'true' || val === true, z.boolean()) .preprocess((val) => val === 'true' || val === true, z.boolean())
.optional(), .optional()
structureType: z.string().optional().or(z.literal('')), .nullable(),
structureType: z.string().optional().or(z.literal('')).nullable(),
isOpenSpace: z isOpenSpace: z
.preprocess((val) => val === 'true' || val === true, z.boolean()) .preprocess((val) => val === 'true' || val === true, z.boolean())
.optional(), .optional()
paralysisReason: z.string().optional().default(''), .nullable(),
paralysisReason: z.string().optional().nullable(),
//Datos del Equipamiento //Datos del Equipamiento
equipmentList: z.array(equipmentItemSchema).optional().default([]), equipmentList: z.array(equipmentItemSchema).optional().default([]),
@@ -92,29 +99,47 @@ export const trainingSchema = z.object({
ospAddress: z ospAddress: z
.string() .string()
.min(1, { message: 'Dirección de la OSP es requerida' }), .min(1, { message: 'Dirección de la OSP es requerida' }),
ospGoogleMapsLink: z.string().optional().or(z.literal('')), ospGoogleMapsLink: z.string().optional().or(z.literal('')).nullable(),
communeName: z.string().optional().or(z.literal('')), communeName: z.string().optional().or(z.literal('')).nullable(),
siturCodeCommune: z.string().optional().or(z.literal('')), siturCodeCommune: z.string().optional().or(z.literal('')).nullable(),
communeRif: z.string().optional().or(z.literal('')), communeRif: z.string().optional().or(z.literal('')).nullable(),
communeSpokespersonName: z.string().optional().or(z.literal('')), communeSpokespersonName: z.string().optional().or(z.literal('')).nullable(),
communeSpokespersonPhone: z.string().optional().or(z.literal('')), communeSpokespersonPhone: z
.string()
.optional()
.or(z.literal(''))
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
communeEmail: z communeEmail: z
.string() .string()
.email({ message: 'Correo electrónico de la Comuna inválido' }) .email({ message: 'Correo electrónico de la Comuna inválido' })
.optional() .optional()
.or(z.literal('')), .or(z.literal(''))
.nullable(),
communalCouncil: z communalCouncil: z
.string() .string()
.min(1, { message: 'Consejo Comunal es requerido' }), .min(1, { message: 'Consejo Comunal es requerido' }),
siturCodeCommunalCouncil: z.string().optional().or(z.literal('')), siturCodeCommunalCouncil: z.string().optional().or(z.literal('')).nullable(),
communalCouncilRif: z.string().optional().or(z.literal('')), communalCouncilRif: z.string().optional().or(z.literal('')).nullable(),
communalCouncilSpokespersonName: z.string().optional().or(z.literal('')), communalCouncilSpokespersonName: z
communalCouncilSpokespersonPhone: z.string().optional().or(z.literal('')), .string()
.optional()
.or(z.literal(''))
.nullable(),
communalCouncilSpokespersonPhone: z
.string()
.optional()
.or(z.literal(''))
.refine((val) => !val || /^(04|02)\d{9}$/.test(val), {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
communalCouncilEmail: z communalCouncilEmail: z
.string() .string()
.email({ message: 'Correo electrónico del Consejo Comunal inválido' }) .email({ message: 'Correo electrónico del Consejo Comunal inválido' })
.optional() .optional()
.or(z.literal('')), .or(z.literal(''))
.nullable(),
//Datos del Responsable OSP //Datos del Responsable OSP
ospResponsibleCedula: z ospResponsibleCedula: z
@@ -123,25 +148,26 @@ export const trainingSchema = z.object({
ospResponsibleFullname: z ospResponsibleFullname: z
.string() .string()
.min(1, { message: 'Nombre del responsable es requerido' }), .min(1, { message: 'Nombre del responsable es requerido' }),
ospResponsibleRif: z ospResponsibleRif: z.string().optional().nullable(),
.string() civilState: z.string().optional().nullable(),
.min(1, { message: 'RIF del responsable es requerido' }),
civilState: z.string().min(1, { message: 'Estado civil es requerido' }),
ospResponsiblePhone: z ospResponsiblePhone: z
.string() .string()
.min(1, { message: 'Teléfono del responsable es requerido' }), .min(1, { message: 'Teléfono del responsable es requerido' })
.regex(/^(04|02)\d{9}$/, {
message: 'El teléfono debe tener 11 dígitos y comenzar con 04 o 02',
}),
ospResponsibleEmail: z ospResponsibleEmail: z
.string() .string()
.email({ message: 'Correo electrónico inválido' }), .email({ message: 'Correo electrónico inválido' })
familyBurden: z.coerce .optional()
.number() .or(z.literal(''))
.min(0, { message: 'Carga familiar requerida' }), .nullable(),
numberOfChildren: z.coerce
.number() familyBurden: z.coerce.number().optional(),
.min(0, { message: 'Número de hijos requerido' }), numberOfChildren: z.coerce.number().optional(),
//Datos adicionales //Datos adicionales
generalObservations: z.string().optional().default(''), generalObservations: z.string().optional().nullable(),
//IMAGENES //IMAGENES
files: z.any().optional(), files: z.any().optional(),
@@ -157,6 +183,9 @@ export const trainingSchema = z.object({
photo2: z.string().optional().nullable(), photo2: z.string().optional().nullable(),
photo3: z.string().optional().nullable(), photo3: z.string().optional().nullable(),
createdBy: z.number().optional().nullable(), createdBy: z.number().optional().nullable(),
updatedBy: z.number().optional().nullable(),
createdAt: z.string().optional().nullable(),
updatedAt: z.string().optional().nullable(),
}); });
export type TrainingSchema = z.infer<typeof trainingSchema>; export type TrainingSchema = z.infer<typeof trainingSchema>;

View File

@@ -8,111 +8,131 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.9751 0.0127 244.2507); --background: oklch(0.9779 0.0042 56.3756);
--foreground: oklch(0.3729 0.0306 259.7328); --foreground: oklch(0.2178 0 0);
--card: oklch(1.0000 0 0); --card: oklch(0.9779 0.0042 56.3756);
--card-foreground: oklch(0.3729 0.0306 259.7328); --card-foreground: oklch(0.2178 0 0);
--popover: oklch(1.0000 0 0); --popover: oklch(0.9779 0.0042 56.3756);
--popover-foreground: oklch(0.3729 0.0306 259.7328); --popover-foreground: oklch(0.2178 0 0);
--primary: oklch(0.7227 0.1920 149.5793); --primary: oklch(0.465 0.147 24.9381);
--primary-foreground: oklch(1.0000 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9514 0.0250 236.8242); --secondary: oklch(0.9625 0.0385 89.0943);
--secondary-foreground: oklch(0.4461 0.0263 256.8018); --secondary-foreground: oklch(0.4847 0.1022 75.1153);
--muted: oklch(0.9670 0.0029 264.5419); --muted: oklch(0.9431 0.0068 53.4442);
--muted-foreground: oklch(0.5510 0.0234 264.3637); --muted-foreground: oklch(0.4444 0.0096 73.639);
--accent: oklch(0.9505 0.0507 163.0508); --accent: oklch(0.9619 0.058 95.6174);
--accent-foreground: oklch(0.3729 0.0306 259.7328); --accent-foreground: oklch(0.3958 0.1331 25.723);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.4437 0.1613 26.8994);
--destructive-foreground: oklch(1.0000 0 0); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.9276 0.0058 264.5313); --border: oklch(0.9355 0.0324 80.9937);
--input: oklch(0.9276 0.0058 264.5313); --input: oklch(0.9355 0.0324 80.9937);
--ring: oklch(0.7227 0.1920 149.5793); --ring: oklch(0.465 0.147 24.9381);
--chart-1: oklch(0.7227 0.1920 149.5793); --chart-1: oklch(0.5054 0.1905 27.5181);
--chart-2: oklch(0.6959 0.1491 162.4796); --chart-2: oklch(0.465 0.147 24.9381);
--chart-3: oklch(0.5960 0.1274 163.2254); --chart-3: oklch(0.3958 0.1331 25.723);
--chart-4: oklch(0.5081 0.1049 165.6121); --chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4318 0.0865 166.9128); --chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.9514 0.0250 236.8242); --sidebar: oklch(0.9431 0.0068 53.4442);
--sidebar-foreground: oklch(0.3729 0.0306 259.7328); --sidebar-foreground: oklch(0.2178 0 0);
--sidebar-primary: oklch(0.7227 0.1920 149.5793); --sidebar-primary: oklch(0.465 0.147 24.9381);
--sidebar-primary-foreground: oklch(1.0000 0 0); --sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9505 0.0507 163.0508); --sidebar-accent: oklch(0.9619 0.058 95.6174);
--sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328); --sidebar-accent-foreground: oklch(0.3958 0.1331 25.723);
--sidebar-border: oklch(0.9276 0.0058 264.5313); --sidebar-border: oklch(0.9355 0.0324 80.9937);
--sidebar-ring: oklch(0.7227 0.1920 149.5793); --sidebar-ring: oklch(0.465 0.147 24.9381);
--font-sans: DM Sans, sans-serif; --font-sans: Poppins, sans-serif;
--font-serif: Lora, serif; --font-serif: Libre Baskerville, serif;
--font-mono: IBM Plex Mono, monospace; --font-mono: IBM Plex Mono, monospace;
--radius: 0.5rem; --radius: 0.375rem;
--shadow-x: 0px; --shadow-x: 1px;
--shadow-y: 4px; --shadow-y: 1px;
--shadow-blur: 8px; --shadow-blur: 16px;
--shadow-spread: -1px; --shadow-spread: -2px;
--shadow-opacity: 0.1; --shadow-opacity: 0.12;
--shadow-color: hsl(0 0% 0%); --shadow-color: hsl(0 63% 18%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); --shadow-2xs: 1px 1px 16px -2px hsl(0 63% 18% / 0.06);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); --shadow-xs: 1px 1px 16px -2px hsl(0 63% 18% / 0.06);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); --shadow-sm:
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); 1px 1px 16px -2px hsl(0 63% 18% / 0.12),
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10); 1px 1px 2px -3px hsl(0 63% 18% / 0.12);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10); --shadow:
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10); 1px 1px 16px -2px hsl(0 63% 18% / 0.12),
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25); 1px 1px 2px -3px hsl(0 63% 18% / 0.12);
--shadow-md:
1px 1px 16px -2px hsl(0 63% 18% / 0.12),
1px 2px 4px -3px hsl(0 63% 18% / 0.12);
--shadow-lg:
1px 1px 16px -2px hsl(0 63% 18% / 0.12),
1px 4px 6px -3px hsl(0 63% 18% / 0.12);
--shadow-xl:
1px 1px 16px -2px hsl(0 63% 18% / 0.12),
1px 8px 10px -3px hsl(0 63% 18% / 0.12);
--shadow-2xl: 1px 1px 16px -2px hsl(0 63% 18% / 0.3);
--tracking-normal: 0em; --tracking-normal: 0em;
--spacing: 0.25rem; --spacing: 0.25rem;
} }
.dark { .dark {
--background: oklch(0.2077 0.0398 265.7549); --background: oklch(0.2161 0.0061 56.0434);
--foreground: oklch(0.8717 0.0093 258.3382); --foreground: oklch(0.9699 0.0013 106.4238);
--card: oklch(0.2795 0.0368 260.0310); --card: oklch(0.2685 0.0063 34.2976);
--card-foreground: oklch(0.8717 0.0093 258.3382); --card-foreground: oklch(0.9699 0.0013 106.4238);
--popover: oklch(0.2795 0.0368 260.0310); --popover: oklch(0.2685 0.0063 34.2976);
--popover-foreground: oklch(0.8717 0.0093 258.3382); --popover-foreground: oklch(0.9699 0.0013 106.4238);
--primary: oklch(0.7729 0.1535 163.2231); --primary: oklch(0.5054 0.1905 27.5181);
--primary-foreground: oklch(0.2077 0.0398 265.7549); --primary-foreground: oklch(0.9779 0.0042 56.3756);
--secondary: oklch(0.3351 0.0331 260.9120); --secondary: oklch(0.4732 0.1247 46.2007);
--secondary-foreground: oklch(0.7118 0.0129 286.0665); --secondary-foreground: oklch(0.9619 0.058 95.6174);
--muted: oklch(0.2463 0.0275 259.9628); --muted: oklch(0.2291 0.006 56.0708);
--muted-foreground: oklch(0.5510 0.0234 264.3637); --muted-foreground: oklch(0.8687 0.0043 56.366);
--accent: oklch(0.3729 0.0306 259.7328); --accent: oklch(0.5553 0.1455 48.9975);
--accent-foreground: oklch(0.7118 0.0129 286.0665); --accent-foreground: oklch(0.9619 0.058 95.6174);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.2077 0.0398 265.7549); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.4461 0.0263 256.8018); --border: oklch(0.3741 0.0087 67.5582);
--input: oklch(0.4461 0.0263 256.8018); --input: oklch(0.3741 0.0087 67.5582);
--ring: oklch(0.7729 0.1535 163.2231); --ring: oklch(0.5054 0.1905 27.5181);
--chart-1: oklch(0.7729 0.1535 163.2231); --chart-1: oklch(0.7106 0.1661 22.2162);
--chart-2: oklch(0.7845 0.1325 181.9120); --chart-2: oklch(0.6368 0.2078 25.3313);
--chart-3: oklch(0.7227 0.1920 149.5793); --chart-3: oklch(0.5771 0.2152 27.325);
--chart-4: oklch(0.6959 0.1491 162.4796); --chart-4: oklch(0.8369 0.1644 84.4286);
--chart-5: oklch(0.5960 0.1274 163.2254); --chart-5: oklch(0.7686 0.1647 70.0804);
--sidebar: oklch(0.2795 0.0368 260.0310); --sidebar: oklch(0.2161 0.0061 56.0434);
--sidebar-foreground: oklch(0.8717 0.0093 258.3382); --sidebar-foreground: oklch(0.9699 0.0013 106.4238);
--sidebar-primary: oklch(0.7729 0.1535 163.2231); --sidebar-primary: oklch(0.5054 0.1905 27.5181);
--sidebar-primary-foreground: oklch(0.2077 0.0398 265.7549); --sidebar-primary-foreground: oklch(0.9779 0.0042 56.3756);
--sidebar-accent: oklch(0.3729 0.0306 259.7328); --sidebar-accent: oklch(0.5553 0.1455 48.9975);
--sidebar-accent-foreground: oklch(0.7118 0.0129 286.0665); --sidebar-accent-foreground: oklch(0.9619 0.058 95.6174);
--sidebar-border: oklch(0.4461 0.0263 256.8018); --sidebar-border: oklch(0.3741 0.0087 67.5582);
--sidebar-ring: oklch(0.7729 0.1535 163.2231); --sidebar-ring: oklch(0.5054 0.1905 27.5181);
--font-sans: DM Sans, sans-serif; --font-sans: Poppins, sans-serif;
--font-serif: Lora, serif; --font-serif: Libre Baskerville, serif;
--font-mono: IBM Plex Mono, monospace; --font-mono: IBM Plex Mono, monospace;
--radius: 0.5rem; --radius: 0.375rem;
--shadow-x: 0px; --shadow-x: 1px;
--shadow-y: 4px; --shadow-y: 1px;
--shadow-blur: 8px; --shadow-blur: 16px;
--shadow-spread: -1px; --shadow-spread: -2px;
--shadow-opacity: 0.1; --shadow-opacity: 0.12;
--shadow-color: hsl(0 0% 0%); --shadow-color: hsl(0 63% 18%);
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); --shadow-2xs: 1px 1px 16px -2px hsl(0 63% 18% / 0.06);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); --shadow-xs: 1px 1px 16px -2px hsl(0 63% 18% / 0.06);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); --shadow-sm:
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); 1px 1px 16px -2px hsl(0 63% 18% / 0.12),
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10); 1px 1px 2px -3px hsl(0 63% 18% / 0.12);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10); --shadow:
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10); 1px 1px 16px -2px hsl(0 63% 18% / 0.12),
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25); 1px 1px 2px -3px hsl(0 63% 18% / 0.12);
--shadow-md:
1px 1px 16px -2px hsl(0 63% 18% / 0.12),
1px 2px 4px -3px hsl(0 63% 18% / 0.12);
--shadow-lg:
1px 1px 16px -2px hsl(0 63% 18% / 0.12),
1px 4px 6px -3px hsl(0 63% 18% / 0.12);
--shadow-xl:
1px 1px 16px -2px hsl(0 63% 18% / 0.12),
1px 8px 10px -3px hsl(0 63% 18% / 0.12);
--shadow-2xl: 1px 1px 16px -2px hsl(0 63% 18% / 0.3);
} }
@theme inline { @theme inline {