Merge branch 'inventory'

This commit is contained in:
2025-09-23 10:44:54 -04:00
84 changed files with 12150 additions and 317 deletions

View File

@@ -37,19 +37,19 @@
}
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.40.0",
"express": "^5.1.0",
"joi": "^17.13.3",
"moment": "^2.30.1",
"path-to-regexp": "^8.2.0",
"pg": "^8.13.3",
"pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
"@nestjs/common": "11.0.0",
"@nestjs/core": "11.0.0",
"@nestjs/platform-express": "11.0.0",
"dotenv": "16.5.0",
"drizzle-orm": "0.40.0",
"express": "5.1.0",
"joi": "17.13.3",
"moment": "2.30.1",
"path-to-regexp": "8.2.0",
"pg": "8.13.3",
"pino-pretty": "13.0.0",
"reflect-metadata": "0.2.0",
"rxjs": "7.8.1"
},
"devDependencies": {
"@nestjs-modules/mailer": "^2.0.2",

View File

@@ -18,7 +18,8 @@ import { MailModule } from './features/mail/mail.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 {InventoryModule} from './features/inventory/inventory.module'
import { PicturesModule } from './features/pictures/pictures.module';
@Module({
providers: [
@@ -58,7 +59,9 @@ import { SurveysModule } from './features/surveys/surveys.module';
UserRolesModule,
ConfigurationsModule,
SurveysModule,
LocationModule
LocationModule,
InventoryModule,
PicturesModule
],
})
export class AppModule {}

View File

@@ -24,7 +24,7 @@ export class JwtRefreshGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
throw new UnauthorizedException('No Refresh Token?');
}
try {
request.user = await this.jwtService.verifyAsync(token, {
@@ -43,7 +43,14 @@ export class JwtRefreshGuard implements CanActivate {
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
const token = request.body.refresh_token
// console.log(token);
if (token) {
return token;
}
// console.log(request.headers.authorization);
// const [type, token] = request.headers.authorization?.split(' ') ?? [];
// return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -3,3 +3,4 @@ export * from './schema/activity_logs';
export * from './schema/auth';
export * from './schema/general';
export * from './schema/surveys'
export * from './schema/inventory'

View File

@@ -0,0 +1,13 @@
CREATE TABLE "products" (
"id" serial PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"price" numeric NOT NULL,
"stock" integer NOT NULL,
"url_img" text NOT NULL,
"user_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp (3)
);
--> statement-breakpoint
ALTER TABLE "products" ADD CONSTRAINT "products_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "products" ALTER COLUMN "user_id" SET NOT NULL;--> statement-breakpoint
CREATE VIEW "public"."v_product_store" AS (
select p.id as product_id, p.title, p.description, p.price, p.stock, p.url_img, p.user_id, u.fullname
from products p
left join auth.users as u on u.id = p.user_id);

View File

@@ -0,0 +1,6 @@
DROP VIEW "public"."v_product_store";--> statement-breakpoint
ALTER TABLE "products" ADD COLUMN "address" text NOT NULL;--> statement-breakpoint
CREATE VIEW "public"."v_product_store" AS (
select p.id as product_id, p.title, p.description, p.price, p.stock, p.url_img, p.user_id, u.fullname, u.email, u.phone
from products p
left join auth.users as u on u.id = p.user_id);

View File

@@ -0,0 +1,5 @@
DROP VIEW "public"."v_product_store";--> statement-breakpoint
CREATE VIEW "public"."v_product_store" AS (
select p.id as product_id, p.title, p.description, p.price, p.stock, p.url_img, p.address, p.user_id, u.fullname, u.email, u.phone
from products p
left join auth.users as u on u.id = p.user_id);

View File

@@ -0,0 +1,8 @@
DROP VIEW "public"."v_product_store";--> statement-breakpoint
ALTER TABLE "products" ALTER COLUMN "price" SET DEFAULT '0';--> statement-breakpoint
ALTER TABLE "products" ALTER COLUMN "stock" SET DEFAULT 0;--> statement-breakpoint
ALTER TABLE "products" ADD COLUMN "status" text DEFAULT 'BORRADOR' NOT NULL;--> statement-breakpoint
CREATE VIEW "public"."v_product_store" AS (
select p.id as product_id, p.title, p.description, p.price, p.stock, p.url_img, p.address, p.status, p.user_id, u.fullname, u.email, u.phone
from products p
left join auth.users as u on u.id = p.user_id);

View File

@@ -0,0 +1,6 @@
DROP VIEW "public"."v_product_store";--> statement-breakpoint
ALTER TABLE "products" ADD COLUMN "gallery" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
CREATE VIEW "public"."v_product_store" AS (
select p.id as product_id, p.title, p.description, p.price, p.stock, p.url_img, p.gallery, p.address, p.status, p.user_id, u.fullname, u.email, u.phone
from products p
left join auth.users as u on u.id = p.user_id);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,48 @@
"when": 1747665408016,
"tag": "0001_massive_kylun",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1750442271575,
"tag": "0002_polite_franklin_richards",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1751482400155,
"tag": "0003_icy_gertrude_yorkes",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752500116385,
"tag": "0004_colorful_aqueduct",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752500607554,
"tag": "0005_little_bloodscream",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1752507413748,
"tag": "0006_real_tyger_tiger",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1754420096323,
"tag": "0007_curved_fantastic_four",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,40 @@
import * as t from 'drizzle-orm/pg-core';
import { timestamps } from '../timestamps';
import { users } from './auth';
import { sql } from 'drizzle-orm';
export const products = t.pgTable(
'products',
{
id: t.serial('id').primaryKey(),
title: t.text('title').notNull(),
description: t.text('description').notNull(),
price: t.numeric('price').notNull().default('0'),
stock: t.integer('stock').notNull().default(0),
address: t.text('address').notNull(),
urlImg: t.text('url_img').notNull(),
gallery: t.text('gallery').array().notNull().default(sql`'{}'::text[]`),
status: t.text('status').notNull().default('BORRADOR'),
userId: t.integer('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
...timestamps,
}
);
export const viewProductsStore = t.pgView('v_product_store', {
id: t.integer('product_id'),
title: t.text('title'),
description: t.text('description'),
price: t.numeric('price'),
stock: t.integer('stock'),
urlImg: t.text('url_img'),
gallery: t.text('gallery'),
address: t.text('address'),
status: t.text('status'),
userId: t.integer('user_id'),
fullname: t.text('fullname'),
email: t.text('email'),
phone: t.text('phone')
}).as(sql`
select p.id as product_id, p.title, p.description, p.price, p.stock, p.url_img, p.gallery, p.address, p.status, p.user_id, u.fullname, u.email, u.phone
from products p
left join auth.users as u on u.id = p.user_id`);

View File

@@ -8,6 +8,7 @@ import { seedMunicipalites } from './municipalities';
import { seedParishes } from './parishes';
import { seedStates } from './states';
import { seedUserAdmin } from './user-admin.seed';
import {seedProducts} from './inventory.seed'
async function main() {
const pool = new Pool({
@@ -25,6 +26,7 @@ async function main() {
await seedLocalities(db);
await seedAdminRole(db);
await seedUserAdmin(db);
await seedProducts(db);
console.log('All seeds completed successfully');
} catch (error) {

View File

@@ -0,0 +1,42 @@
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../index';
import { products } from '../schema/inventory';
export async function seedProducts(db: NodePgDatabase<typeof schema>) {
console.log('Seeding example product...');
// Insert inventory
const array = [
{
title:'manzana',
description:'Fruta pequeña y roja, extraída de los árboles de nuestra fundación, de increíble sabor',
price:'100',
stock:10,
address:"Calle 1",
status:'PUBLICADO', // PUBLICADO, AGOTADO, BORRADOR
urlImg:'apple.avif',
userId:1,
gallery: ["Pruebas.png","Pruebas2.png"]
}
];
for (const item of array) {
try {
// await db.insert(products).values({
// title: item.title,
// description: item.description,
// price: item.price,
// stock: item.stock,
// address: item.address,
// urlImg: item.urlImg,
// userId: item.userId
// }).onConflictDoNothing();
await db.insert(products).values(item).onConflictDoNothing();
} catch (error) {
console.error(`Error creating products '${item.title}':`, error);
}
}
console.log('All products seeded successfully');
}

View File

@@ -11,6 +11,7 @@ import {
HttpCode,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
@@ -51,16 +52,31 @@ export class AuthController {
// }
@UseGuards(JwtRefreshGuard)
@Patch('refresh-token')
//@RequirePermissions('auth:refresh-token')
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return await this.authService.refreshToken(refreshTokenDto);
}
@Public()
@HttpCode(200)
@Get('test')
async test() {
return 'aplication test success';
@Patch('refresh')
//@RequirePermissions('auth:refresh-token')
async refreshToken(@Req() req: Request,@Body() refreshTokenDto: RefreshTokenDto) {
// console.log("Pepeeeee");
// console.log(req['user']);
// console.log("refreshTokenDto",refreshTokenDto);
// console.log(typeof refreshTokenDto);
const data = await this.authService.refreshToken(refreshTokenDto,req['user'].sub);
// console.log("data",data);
if (!data) {
return null;
}
return {tokens: data}
}
// @Public()
// @HttpCode(200)
// @Get('test')
// async test() {
// return 'aplication test success';
// }
}

View File

@@ -261,8 +261,9 @@ export class AuthService {
}
//Refresh User Access Token
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
const { user_id } = dto;
async refreshToken(dto: RefreshTokenDto,user_id:number): Promise<RefreshTokenInterface> {
// const { user_id } = dto;
// const user_id = 1;
const session = await this.drizzle
.select()
@@ -274,16 +275,22 @@ export class AuthService {
),
);
// console.log(session.length);
if (session.length === 0) throw new NotFoundException('session not found');
const user = await this.findUserById(dto.user_id);
const user = await this.findUserById(user_id);
if (!user) throw new NotFoundException('User not found');
// Genera token
const tokens = await this.generateTokens(user);
const decodeAccess = this.decodeToken(tokens.access_token);
const decodeRefresh = this.decodeToken(tokens.refresh_token);
// Actualiza session
await this.drizzle
.update(sessions)
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
.where(eq(sessions.userId, dto.user_id));
.where(eq(sessions.userId, user_id));
return {
access_token: tokens.access_token,

View File

@@ -8,7 +8,7 @@ export class RefreshTokenDto {
})
refresh_token: string;
@ApiProperty()
@IsNumber()
user_id: number;
// @ApiProperty()
// @IsNumber()
// user_id: number;
}

View File

@@ -0,0 +1,48 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsInt, IsOptional, IsString } from 'class-validator';
export class CreateProductDto {
@ApiProperty()
@IsString()
title: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty()
@IsString({
message: 'price must be a string',
})
@IsOptional()
price: string;
@ApiProperty()
@IsInt({
message: 'stock must be a number',
})
@IsOptional()
stock: number;
@ApiProperty()
@IsString({
message: 'address must be a string',
})
@IsOptional()
address: string;
@ApiProperty()
@IsString({
message: 'address must be a string',
})
@IsOptional()
status: string;
@ApiProperty()
@IsOptional()
userId: number;
@ApiProperty()
@IsOptional()
urlImg: string;
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { CreateProductDto } from './create-product.dto';
// import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class UpdateProductDto extends PartialType(CreateProductDto) {
@ApiProperty()
@IsString({
message: 'id must be a number',
})
id: string;
@IsOptional()
title: string;
@IsOptional()
description: string;
@IsOptional()
price: string;
@IsOptional()
stock: number;
@IsOptional()
address: string;
@IsOptional()
status: string;
@IsOptional()
urlImg: string;
// @ApiProperty()
// @IsString({
// message: 'userId must be a number',
// })
// userId: number;
}

View File

@@ -0,0 +1,43 @@
export class Product {
id?: number | null;
title: string | null;
description: string | null;
price: string | null;
stock: number | null;
urlImg: string | null;
userId?: number | null;
fullname?: string | null;
}
export class Inventory {
id: number | null;
title: string | null;
description: string | null;
price: string | null;
stock: number | null;
urlImg: string | null;
gallery: string[] | null;
address: string | null;
status: string | null;
}
export class Store {
id: number | null;
title: string | null;
description: string | null;
price: string | null;
stock: number | null;
urlImg: string | null;
userId: number | null;
fullname?: string | null;
}
// export class CreateProduct {
// id: number;
// title: string;
// description: string;
// price: string;
// stock: string;
// urlImg: string;
// UserId: number;
// }

View File

@@ -0,0 +1,99 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { InventoryService } from './inventory.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
// import { Roles } from '../../common/decorators/roles.decorator';
import { PaginationDto } from '../../common/dto/pagination.dto';
@ApiTags('products')
@Controller('products')
export class UsersController {
constructor(private readonly inventoryService: InventoryService) {}
@Get('/store')
// @Roles('admin')
@ApiOperation({ summary: 'Get all products with pagination and filters' })
@ApiResponse({ status: 200, description: 'Return paginated products.' })
async findAll(@Query() paginationDto: PaginationDto) {
const result = await this.inventoryService.findAll(paginationDto,true);
return {
message: 'products fetched successfully',
data: result.data,
meta: result.meta
};
}
@Get('/inventory')
// @Roles('admin')
@ApiOperation({ summary: 'Get all products with pagination and filters' })
@ApiResponse({ status: 200, description: 'Return paginated products.' })
async findAllByUserId(@Req() req: Request, @Query() paginationDto: PaginationDto) {
// console.log(req['user'].id)
// const id = 1
const id = Number(req['user'].id);
const result = await this.inventoryService.findAllByUserId(id,paginationDto);
return {
message: 'products fetched successfully',
data: result.data,
meta: result.meta
};
}
@Get('/id/:id')
// @Roles('admin')
@ApiOperation({ summary: 'Get a product by ID' })
@ApiResponse({ status: 200, description: 'Return the product.' })
@ApiResponse({ status: 404, description: 'product not found.' })
async findOne(@Param('id') id: string) {
const productId = Number(id)
const data = await this.inventoryService.findOne(productId);
return { message: 'product fetched successfully', data };
}
@Post()
@ApiOperation({ summary: 'Create a new product' })
@ApiResponse({ status: 201, description: 'Product created successfully.' })
@ApiResponse({ status: 500, description: 'Internal server error.' })
@UseInterceptors(FilesInterceptor('urlImg'))
async create(
@Req() req: Request,
@Body() createUserDto: CreateProductDto,
@UploadedFiles() files: Express.Multer.File[],
// @Query('roleId') roleId?: string,
) {
const id = Number(req['user'].id);
const data = await this.inventoryService.create(files,createUserDto,id)
return { message: 'User created successfully', data };
}
@Patch('/upload')
@ApiOperation({ summary: 'Update a product' })
@ApiResponse({ status: 200, description: 'Product uploaded successfully.'})
@ApiResponse({ status: 404, description: 'Product not found.' })
@ApiResponse({ status: 500, description: 'Internal server error.' })
@UseInterceptors(FilesInterceptor('urlImg'))
async uploadFile(
@Req() req: Request,
@UploadedFiles() files: Express.Multer.File[],
@Body() body: any
) {
const id = Number(req['user'].id);
const result = await this.inventoryService.update(files,body,id);
return { data: result };
}
@Delete(':id')
// @Roles('admin')
@ApiOperation({ summary: 'Delete a Product' })
@ApiResponse({ status: 200, description: 'Product deleted successfully.' })
@ApiResponse({ status: 404, description: 'Product not found.' })
async remove(@Req() req: Request, @Param('id') id: string) {
const productId = Number(id);
const userId = Number(req['user'].id);
return await this.inventoryService.remove(productId,userId);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UsersController } from './inventory.controller';
import { InventoryService } from './inventory.service';
import { DrizzleModule } from '@/database/drizzle.module';
@Module({
imports: [DrizzleModule],
controllers: [UsersController],
providers: [InventoryService],
})
export class InventoryModule {}

View File

@@ -0,0 +1,321 @@
import { DRIZZLE_PROVIDER } from 'src/database/drizzle-provider';
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from 'src/database/index';
import { products, viewProductsStore } from 'src/database/index';
import { eq, like, or, SQL, sql, and, not, ne } from 'drizzle-orm';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { Product, Store, Inventory } from './entities/inventory.entity';
import { PaginationDto } from '../../common/dto/pagination.dto';
// Para guardar la imagen
import { writeFile, mkdir, unlink, rmdir, rm } from 'fs/promises';
import { join } from 'path';
@Injectable()
export class InventoryService {
constructor(
@Inject(DRIZZLE_PROVIDER) private drizzle: NodePgDatabase<typeof schema>,
) { }
async findAllByUserId(id: number, paginationDto?: PaginationDto): Promise<{ data: Inventory[], meta: any }> {
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
// Calculate offset
const offset = (page - 1) * limit;
// Build search condition
let searchCondition: SQL<unknown> | undefined;
if (search) {
searchCondition = and(
or(
like(products.title, `%${search}%`),
like(products.description, `%${search}%`),
),
and(
eq(products.userId, id),
ne(products.status, 'ELIMINADO')
)
)
} else {
searchCondition = and(
eq(products.userId, id),
ne(products.status, 'ELIMINADO')
)
}
// Build sort condition
const orderBy = sortOrder === 'asc'
? sql`${products[sortBy as keyof typeof products]} asc`
: sql`${products[sortBy as keyof typeof products]} desc`;
// Get total count for pagination
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(products)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
// Get paginated data
const data = await this.drizzle
.select({
id: products.id,
title: products.title,
description: products.description,
address: products.address,
price: products.price,
stock: products.stock,
status: products.status,
urlImg: products.urlImg,
gallery: products.gallery,
userId: products.userId
})
.from(products)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
// Build pagination metadata
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
}
async findAll(paginationDto?: PaginationDto, isStore: boolean = false): Promise<{ data: Store[], meta: any }> {
const { page = 1, limit = 10, search = '', sortBy = 'id', sortOrder = 'asc' } = paginationDto || {};
// Calculate offset
const offset = (page - 1) * limit;
// Build search condition
let searchCondition: SQL<unknown> | undefined;
if (search && isStore) {
searchCondition = and(
or(
like(viewProductsStore.title, `%${search}%`),
like(viewProductsStore.description, `%${search}%`)
),
and(ne(viewProductsStore.status, 'BORRADOR'), ne(viewProductsStore.status, 'ELIMINADO'))
)
} else if (search) {
or(
like(viewProductsStore.title, `%${search}%`),
like(viewProductsStore.description, `%${search}%`)
)
} else if (isStore) {
searchCondition = and(ne(viewProductsStore.status, 'BORRADOR'), ne(viewProductsStore.status, 'ELIMINADO'))
}
// Build sort condition
const orderBy = sortOrder === 'asc'
? sql`${viewProductsStore[sortBy as keyof typeof viewProductsStore]} asc`
: sql`${viewProductsStore[sortBy as keyof typeof viewProductsStore]} desc`;
// Get total count for pagination
const totalCountResult = await this.drizzle
.select({ count: sql<number>`count(*)` })
.from(viewProductsStore)
.where(searchCondition);
const totalCount = Number(totalCountResult[0].count);
const totalPages = Math.ceil(totalCount / limit);
// Get paginated data
const data = await this.drizzle
.select({
id: viewProductsStore.id,
title: viewProductsStore.title,
description: viewProductsStore.description,
price: viewProductsStore.price,
address: viewProductsStore.address,
urlImg: viewProductsStore.urlImg,
stock: viewProductsStore.stock,
status: viewProductsStore.status,
userId: viewProductsStore.userId,
fullname: viewProductsStore.fullname,
email: viewProductsStore.email,
phone: viewProductsStore.phone
})
.from(viewProductsStore)
.where(searchCondition)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
// Build pagination metadata
const meta = {
page,
limit,
totalCount,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
return { data, meta };
}
async findOne(id: number): Promise<Product> {
const find = await this.drizzle
.select({
id: viewProductsStore.id,
title: viewProductsStore.title,
description: viewProductsStore.description,
price: viewProductsStore.price,
address: viewProductsStore.address,
urlImg: viewProductsStore.urlImg,
gallery: viewProductsStore.gallery,
stock: viewProductsStore.stock,
status: viewProductsStore.status,
userId: viewProductsStore.userId,
fullname: viewProductsStore.fullname,
email: viewProductsStore.email,
phone: viewProductsStore.phone
})
.from(viewProductsStore)
.where(and(eq(viewProductsStore.id, id), ne(viewProductsStore.status, 'ELIMINADO')));
if (find.length === 0) {
throw new HttpException('Product does not exist', HttpStatus.NOT_FOUND);
}
return find[0];
}
// Rest of the service remains the same
async create(
file: Express.Multer.File[],
createProductDto: CreateProductDto,
userId: number,
): Promise<any> {
let gallery: string[] = [];
await Promise.all(file.map(async (f, index) => {
const fileName = `${index + 1}-${f.originalname}`;
gallery.push(fileName);
}));
// console.log(gallery);
// Start a transaction
return await this.drizzle.transaction(async (tx) => {
const productValue = {
title: createProductDto.title,
description: createProductDto.description,
price: createProductDto.price,
address: createProductDto.address,
status: createProductDto.status,
urlImg: gallery[0],
stock: createProductDto.stock,
userId: userId,
gallery: gallery
}
// console.log(productValue);
const [newProduct] = await tx
.insert(products)
.values(productValue)
.returning();
const productId = newProduct.id;
const picturesPath = join(__dirname, '..', '..', '..', '..', 'web', 'public', 'uploads', 'inventory',userId.toString() , productId.toString());
// Crea el directorio si no existe
await mkdir(picturesPath, { recursive: true });
await Promise.all(file.map(async (f, index) => {
const fileName = `${index + 1}-${f.originalname}`;
const filePath = join(picturesPath, fileName);
await writeFile(filePath, f.buffer);
}));
return newProduct
})
}
async update(file: Express.Multer.File[], updateProductDto: UpdateProductDto, userId: number): Promise<Product> {
const productId = parseInt(updateProductDto.id);
// Check if exists
await this.findOne(productId);
let gallery: string[] = [];
// check if product exist
if (file && file.length > 0) {
// Construye la ruta al directorio de imágenes.
const picturesPath = join(__dirname, '..', '..', '..', '..', 'web', 'public', 'uploads', 'inventory', userId.toString() , productId.toString());
try {
// Borra el directorio y todos sus contenidos de forma recursiva y forzada.
await rm(picturesPath, { recursive: true, force: true });
} catch (error) {
// Es buena práctica manejar el error, aunque `force: true` lo hace menos probable.
// Podrías registrar el error, pero no detener la ejecución.
console.error(`No se pudo eliminar el directorio ${picturesPath}:`, error);
}
// Crea el directorio si no existe (ya que lo acabamos de borrar o no existía).
await mkdir(picturesPath, { recursive: true });
// Usamos `Promise.all` para manejar las operaciones asíncronas de forma correcta.
await Promise.all(file.map(async (f, index) => {
const fileName = `${index + 1}-${f.originalname}`;
gallery.push(fileName);
const filePath = join(picturesPath, fileName);
await writeFile(filePath, f.buffer);
}));
}
// Prepare update data
const updateData: any = {};
if (updateProductDto.title) updateData.title = updateProductDto.title;
if (updateProductDto.description) updateData.description = updateProductDto.description;
if (updateProductDto.price) updateData.price = updateProductDto.price;
if (updateProductDto.address) updateData.address = updateProductDto.address;
if (updateProductDto.status) updateData.status = updateProductDto.status;
if (updateProductDto.stock) updateData.stock = updateProductDto.stock;
if (file && file.length > 0) updateData.gallery = gallery;
if (file && file.length > 0) updateData.urlImg = gallery[0];
const [updatedProduct] = await this.drizzle.update(products).set(updateData).where(eq(products.id, productId)).returning();
return updatedProduct;
}
async remove(productId: number, userId: number): Promise<{ message: string }> {
// const picturesPath = join(__dirname, '..', '..', '..', '..', 'web', 'public', 'uploads', 'inventory', userId.toString() , productId.toString());
// try {
// // Borra el directorio y todos sus contenidos de forma recursiva y forzada.
// await rm(picturesPath, { recursive: true, force: true });
// } catch (error) {
// console.error(`No se pudo eliminar el directorio ${picturesPath}:`, error);
// }
// Check if exists
await this.findOne(productId);
// Delete user (this will cascade delete related records due to foreign key constraints)
// await this.drizzle.delete(products).where(eq(products.id, productId));
await this.drizzle.update(products).set({ status: 'ELIMINADO' }).where(eq(products.id, productId));
return { message: 'Product deleted successfully' };
}
}

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '@/database/index';
import { CreateSurveyDto } from './dto/create-survey.dto';
import { UpdateSurveyDto } from './dto/update-survey.dto';
import { and, count, eq, ilike, isNull, or, sql } from 'drizzle-orm';
import { and, count, eq, ilike, isNull, like, or, sql } from 'drizzle-orm';
import { SurveyDetailDto, SurveyStatisticsResponseDto } from './dto/statistics-response.dto';
import { PaginationDto } from '@/common/dto/pagination.dto';
import { AnswersSurveyDto } from './dto/response-survey.dto';
@@ -92,7 +92,15 @@ export class SurveysService {
// }
if (findForUserDto.rol[0].rol !== 'superadmin' && findForUserDto.rol[0].rol !== 'admin') {
searchCondition = or(eq(surveys.targetAudience, findForUserDto.rol[0].rol), eq(surveys.targetAudience, 'all'))
searchCondition = and(
or(
eq(surveys.targetAudience, findForUserDto.rol[0].rol),
eq(surveys.targetAudience, 'all')
),
like(surveys.title, `%${search}%`)
)
} else {
searchCondition = like(surveys.title, `%${search}%`)
}
// console.log(searchCondition);

View File

@@ -40,12 +40,11 @@ export class UsersController {
@ApiResponse({ status: 201, description: 'User created successfully.' })
async create(
@Body() createUserDto: CreateUserDto,
@Query('roleId') roleId?: string,
@Query('role') role?: string,
) {
const data = await this.usersService.create(
createUserDto,
roleId ? parseInt(roleId) : undefined,
);
console.log(role);
const data = await this.usersService.create(createUserDto)
return { message: 'User created successfully', data };
}

View File

@@ -159,7 +159,7 @@ export class UsersService {
// Assign role to user
await tx.insert(usersRole).values({
userId: newUser.id,
roleId: roleId,
roleId: createUserDto.role || roleId,
});
// Return the created user with role

View File

@@ -0,0 +1,37 @@
import PageContainer from '@/components/layout/page-container';
import UsersAdminList from '@/feactures/inventory/components/inventory/product-inventory-list';
import { UsersHeader } from '@/feactures/inventory/components/inventory/users-header';
import UsersTableAction from '@/feactures/inventory/components/inventory/product-tables/users-table-action';
import { searchParamsCache, serialize } from '@/feactures/inventory/utils/searchparams';
import { SearchParams } from 'nuqs';
type pageProps = {
searchParams: Promise<SearchParams>;
};
export default async function SurveyAdminPage(props: pageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const key = serialize({ ...searchParams });
const page = Number(searchParamsCache.get('page')) || 1;
const search = searchParamsCache.get('q');
const pageLimit = Number(searchParamsCache.get('limit')) || 10;
const type = searchParamsCache.get('type');
return (
<PageContainer scrollable={false}>
<div className="flex flex-1 flex-col space-y-4">
<UsersHeader />
<UsersTableAction />
<UsersAdminList
initialPage={page}
initialSearch={search}
initialLimit={pageLimit}
initialType={type}
/>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,28 @@
import { getProductById } from '@/feactures/inventory/actions/actions';
import {ProductList} from '@/feactures/inventory/components/products/see-product'
export default async function SurveyResponsePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params; // You can still destructure id from params
if (!id || id === '') return null;
// Call the function passing the dynamic id
const data = await getProductById(Number(id));
if (!data?.data) {
return (
<main className='flex h-full flex-col items-center justify-center'>
<p className='text-2xl'>Lo siento...</p>
<p className='text-4xl text-primary'>Producto no encontrado</p>
</main>
)
}
return (
<ProductList product={data.data} />
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { ProductList } from '@/feactures/inventory/components/products/product-list-scroll';
import { Button } from '@repo/shadcn/components/ui/button';
// import { Metadata } from 'next';
export default function SurveysPage() {
return (
<main className='p-4 md:px-6'>
<header className="w-full flex flex-col sm:flex-row sm:justify-between">
<h1 className="text-2xl font-bold mb-1">Productos disponibles</h1>
<a className='mb-1' href="/dashboard/inventario">
<Button>Mi inventario</Button>
</a>
</header>
<ProductList/>
</main>
);
}

View File

@@ -1,6 +1,7 @@
import {
AlertTriangle,
ArrowRight,
Blocks,
Check,
ChevronLeft,
ChevronRight,
@@ -40,6 +41,7 @@ export type Icon = LucideIcon;
export const Icons = {
dashboard: LayoutDashboardIcon,
blocks: Blocks,
logo: Command,
login: LogIn,
close: X,

View File

@@ -10,7 +10,14 @@ export const GeneralItems: NavItem[] = [
isActive: false,
items: [], // No child items
},
{
title: 'ProduTienda',
url: '/dashboard/productos/',
icon: 'blocks',
shortcut: ['p', 'p'],
isActive: false,
items: [], // No child items
},
];

View File

@@ -0,0 +1,13 @@
export const STATUS = {
PUBLICADO:"Publicado",
AGOTADO:"Agotado",
BORRADOR:"Borrador"
}
export const PRIVATESTATUS = {
PUBLICADO:"Publicado",
AGOTADO:"Agotado",
BORRADOR:"Borrador",
ELIMINADO:"Eliminado",
BLOQUEADO:"Bloqueado"
}

View File

@@ -2,9 +2,6 @@
import { safeFetchApi } from '@/lib';
import { loginResponseSchema, UserFormValue } from '../schemas/login';
type LoginActionSuccess = {
message: string;
user: {

View File

@@ -1,20 +1,27 @@
'use server';
import { safeFetchApi } from '@/lib';
import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia
import {
RefreshTokenResponseSchema,
RefreshTokenValue,
} from '../schemas/refreshToken';
export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
const [error, data] = await safeFetchApi(
RefreshTokenResponseSchema,
'/auth/refreshToken',
'POST',
refreshToken,
);
if (error) {
console.error('Error:', error);
} else {
return data;
try {
const response = await refreshApi.patch('/auth/refresh', {refresh_token: refreshToken.token});
const parsed = RefreshTokenResponseSchema.safeParse(response.data);
if (!parsed.success) {
console.error('Error de validación en la respuesta de refresh token:', {
errors: parsed.error.errors,
receivedData: response.data,
});
return null;
}
return parsed.data;
} catch (error: any) { // Captura el error para acceso a error.response
console.error('Error al renovar el token:', error.response?.data || error.message);
return null;
}
};
};

View File

@@ -0,0 +1,173 @@
'use server';
import { safeFetchApi } from '@/lib/fetch.api';
import {
ApiResponseSchema,
InventoryTable,
productMutate,
productApiResponseSchema,
getProduct,
deleteProduct
} from '../schemas/inventory';
export const getInventoryAction = async (params: {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
...(params.search && { search: params.search }),
...(params.sortBy && { sortBy: params.sortBy }),
...(params.sortOrder && { sortOrder: params.sortOrder }),
});
const [error, response] = await safeFetchApi(
ApiResponseSchema,
`/products/inventory?${searchParams}`,
'GET'
);
if (error) {
console.error(error);
throw new Error(error.message);
}
// const transformedData = response?.data ? transformSurvey(response?.data) : undefined;
return {
data: response?.data || [],
meta: response?.meta || {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 1,
hasNextPage: false,
hasPreviousPage: false,
nextPage: null,
previousPage: null,
},
}
}
export const getAllProducts = async (params: {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}) => {
// const session = await auth()
const searchParams = new URLSearchParams({
page: (params.page || 1).toString(),
limit: (params.limit || 10).toString(),
...(params.search && { search: params.search }),
...(params.sortBy && { sortBy: params.sortBy }),
...(params.sortOrder && { sortOrder: params.sortOrder }),
})
// const id = session?.user.id
const [error, response] = await safeFetchApi(
productApiResponseSchema,
`/products/store?${searchParams}`,
'GET'
);
if (error) {
console.error('Errorrrrr:', error.details);
throw new Error(error.message);
}
return {
data: response?.data || [],
meta: response?.meta || {
page: 1,
limit: 10,
totalCount: 0,
totalPages: 1,
hasNextPage: false,
hasPreviousPage: false,
nextPage: null,
previousPage: null,
},
};
}
export const getProductById = async (id: number) => {
if (!id) {
return null;
}
const [error, data] = await safeFetchApi(
getProduct,
`/products/id/${id}`,
'GET',
);
if (error) {
if (error.details.status === 404){
return null
}
console.error('❌ Error en la API:', error);
throw new Error(error.message);
}
return data;
}
export const createProductAction = async (payload: FormData) => {
const [error, data] = await safeFetchApi(
productMutate,
'/products',
'POST',
payload,
);
if (error) {
console.error(error);
throw new Error('Error al crear el producto');
}
return data;
}
export const updateProductAction = async (payload: InventoryTable) => {
try {
const [error, data] = await safeFetchApi(
productMutate,
`/products/upload`,
'PATCH',
payload,
);
if (error) {
console.error(error);
throw new Error(error?.message || 'Error al actualizar el producto');
}
console.log(data);
return data;
} catch (error) {
console.error(error);
}
}
export const deleteProductAction = async (id: Number) => {
if (!id) {
throw new Error('Error al eliminar el producto')
}
const [error] = await safeFetchApi(
deleteProduct,
`/products/${id}`,
'DELETE'
)
console.log(error);
if (error) throw new Error(error.message || 'Error al eliminar el producto')
return true;
}

View File

@@ -0,0 +1,264 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@repo/shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@repo/shadcn/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { Input } from '@repo/shadcn/input';
import { useForm } from 'react-hook-form';
import { useCreateProduct } from "@/feactures/inventory/hooks/use-mutation";
import { createProduct, EditInventory } from '@/feactures/inventory/schemas/inventory';
import { Textarea } from '@repo/shadcn/textarea';
import { STATUS } from '@/constants/status'
import { useState, useEffect } from 'react';
import { sizeFormate } from "@/feactures/inventory/utils/sizeFormate"
interface CreateFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
export function CreateForm({
onSuccess,
onCancel,
}: CreateFormProps) {
const {
mutate: saveProduct,
isPending: isSaving,
} = useCreateProduct();
const [sizeFile, setSizeFile] = useState('0 bytes');
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
useEffect(() => {
return () => {
previewUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [previewUrls]);
const defaultformValues: EditInventory = {
title: '',
description: '',
price: '',
address: '',
status: 'BORRADOR',
stock: 0,
urlImg: undefined,
};
const form = useForm<EditInventory>({
resolver: zodResolver(createProduct),
defaultValues: defaultformValues,
mode: 'onChange',
});
const onSubmit = async (data: EditInventory) => {
const formData = new FormData();
formData.append('title', data.title);
formData.append('description', data.description);
formData.append('price', String(data.price));
formData.append('address', data.address);
formData.append('status', data.status);
formData.append('stock', String(data.stock));
if (data.urlImg) {
for (let i = 0; i < data.urlImg.length; i++) {
const file = data.urlImg[i];
if (file) {
formData.append('urlImg', file);
}
}
}
saveProduct(formData as any, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
onError: (error) => {
console.error("Error al guardar el producto:", error);
form.setError('root', {
type: 'manual',
message: error.message || 'Error al guardar el producto',
});
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{form.formState.errors.root && (
<div className="text-destructive text-sm">
{form.formState.errors.root.message}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre/Título</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Precio</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className='col-span-2'>
<FormLabel>Dirección</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className='col-span-2'>
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea {...field} className="resize-none" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stock"
render={({ field }) => (
<FormItem>
<FormLabel>Cantidad/Stock</FormLabel>
<FormControl>
<Input {...field} type='number' onChange={(e) => field.onChange(Number(e.target.value))} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Estatus</FormLabel>
<Select value={field.value} onValueChange={(value) => field.onChange(value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleccione un estatus" />
</SelectTrigger>
<SelectContent>
{Object.entries(STATUS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="col-span-2">
<FormField
control={form.control}
name="urlImg"
render={({ field: { onChange, onBlur, name, ref } }) => (
<FormItem>
<FormLabel>Imagen</FormLabel>
<p>Peso máximo: 5MB / {sizeFile} <span className='text-xs text-destructive'>(Máximo 10 archivos)</span></p>
<FormControl>
<Input
type="file"
multiple
onBlur={onBlur}
name={name}
ref={ref}
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
let size = 0;
const newPreviewUrls: string[] = [];
files.forEach(element => {
size += element.size;
newPreviewUrls.push(URL.createObjectURL(element));
});
const tamañoFormateado = sizeFormate(size);
setSizeFile(tamañoFormateado);
setPreviewUrls(newPreviewUrls);
onChange(e.target.files);
} else {
setPreviewUrls([]);
}
}}
/>
</FormControl>
<FormMessage />
{previewUrls.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{previewUrls.map((url, index) => (
<img key={index} src={url} alt={`Preview ${index}`} className="w-24 h-24 object-cover rounded-md" />
))}
</div>
)}
</FormItem>
)}
/>
</div>
</div>
<div className="flex justify-end gap-4">
<Button variant="outline" type="button" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@repo/shadcn/dialog';
// import { AccountPlan } from '@/feactures/users/schemas/account-plan.schema';
import { EditInventory, InventoryTable } from '../../schemas/inventory';
import { CreateForm } from './create-product-form';
import { UpdateForm } from './update-product-form';
interface ModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultValues?: Partial<InventoryTable>;
}
export function AccountPlanModal({
open,
onOpenChange,
defaultValues,
}: ModalProps) {
const handleSuccess = () => {
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
onOpenChange(false);
}
}}
>
<DialogContent className="sm:max-w-[600px] z-50 backdrop-blur-lg bg-background/80">
<DialogHeader>
<DialogTitle>
{defaultValues?.id
? 'Actualizar producto'
: 'Registrar producto'}
</DialogTitle>
<DialogDescription>
Complete los campos para {defaultValues?.id ? 'actualizar' : 'registrar'} un producto
</DialogDescription>
</DialogHeader>
{defaultValues?.id ? (
<UpdateForm
onSuccess={handleSuccess}
onCancel={handleCancel}
defaultValues={defaultValues}
/>
): (
<CreateForm
onSuccess={handleSuccess}
onCancel={handleCancel}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -0,0 +1,108 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertModal } from '@/components/modal/alert-modal';
import { Button } from '@repo/shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn/tooltip';
import { Edit, Trash, Eye } from 'lucide-react';
import { InventoryTable } from '@/feactures/inventory/schemas/inventory';
// import { useDeleteUser } from '@/feactures/users/hooks/use-mutation-users';
import { useDeleteProduct } from "@/feactures/inventory/hooks/use-mutation";
import { AccountPlanModal } from '../inventory-modal';
interface CellActionProps {
data: InventoryTable;
}
export const CellAction: React.FC<CellActionProps> = ({ data }) => {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [edit, setEdit] = useState(false);
const { mutate: deleteUser } = useDeleteProduct();
const router = useRouter();
const onConfirm = async () => {
try {
setLoading(true);
deleteUser(data.id!);
setOpen(false);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
};
return (
<>
<AlertModal
isOpen={open}
onClose={() => setOpen(false)}
onConfirm={onConfirm}
loading={loading}
title="¿Estás seguro que desea deshabilitar este usuario?"
description="Esta acción no se puede deshacer."
/>
<AccountPlanModal open={edit} onOpenChange={setEdit} defaultValues={data}/>
<div className="flex gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/dashboard/productos/${data.id}`)}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ver</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setEdit(true)}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Editar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(true)}
>
<Trash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Eliminar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
);
};

View File

@@ -0,0 +1,45 @@
import { Badge } from "@repo/shadcn/badge";
import { ColumnDef } from '@tanstack/react-table';
import { CellAction } from './cell-action';
import { InventoryTable } from '../../../schemas/inventory';
export const columns: ColumnDef<InventoryTable>[] = [
{
accessorKey: 'urlImg',
header: 'img',
cell: ({ row }) => {
return (
<img src={`/uploads/inventory/${row.original.userId}/${row.original.id}/${row.original.urlImg}`} alt="" width={64} height={64} className="rounded"/>
)
},
},
{
accessorKey: 'title',
header: 'Producto',
},
{
accessorKey: "description",
header: "Descripcion",
cell: ({ row }) => row.original.description.length > 40 ?
`${row.original.description.slice(0, 40)}...` : row.original.description
},
{
accessorKey: 'price',
header: 'Precio',
cell: ({ row }) => `${row.original.price}$`
},
{
accessorKey: 'stock',
header: 'Stock',
},
{
accessorKey: 'status',
header: 'Estado',
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => <CellAction data={row.original} />,
},
];

View File

@@ -0,0 +1,59 @@
'use client';
import { PUBLISHED_TYPES } from '@/feactures/surveys/schemas/surveys-options';
import { searchParams } from '@repo/shadcn/lib/searchparams';
import { useQueryState } from 'nuqs';
import { useCallback, useMemo } from 'react';
export const TYPE_OPTIONS = Object.entries(PUBLISHED_TYPES).map(
([value, label]) => ({
value,
label,
}),
);
export function useSurveyTableFilters() {
const [searchQuery, setSearchQuery] = useQueryState(
'q',
searchParams.q
.withOptions({
shallow: false,
throttleMs: 500, // Add 500ms delay
// Removed dedupingInterval as it's not a valid option
})
.withDefault(''),
);
const [typeFilter, setTypeFilter] = useQueryState(
'published',
searchParams.q.withOptions({ shallow: false }).withDefault(''),
);
const [page, setPage] = useQueryState(
'page',
searchParams.page.withDefault(1),
);
const resetFilters = useCallback(() => {
setSearchQuery(null);
setTypeFilter(null);
setPage(1);
}, [setSearchQuery, setPage]);
const isAnyFilterActive = useMemo(() => {
return !!searchQuery || !!typeFilter;
}, [searchQuery]);
return {
searchQuery,
setSearchQuery,
page,
setPage,
resetFilters,
isAnyFilterActive,
typeFilter,
setTypeFilter
};
}

View File

@@ -0,0 +1,36 @@
'use client';
import { DataTableFilterBox } from '@repo/shadcn/table/data-table-filter-box';
import { DataTableSearch } from '@repo/shadcn/table/data-table-search';
import {
TYPE_OPTIONS,
useSurveyTableFilters,
} from './use-survey-table-filters';
export default function UserTableAction() {
const {
typeFilter,
searchQuery,
setPage,
setTypeFilter,
setSearchQuery,
} = useSurveyTableFilters();
return (
<div className="flex flex-wrap items-center gap-4 pt-2">
<DataTableSearch
searchKey={searchQuery}
searchQuery={searchQuery || ''}
setSearchQuery={setSearchQuery}
setPage={setPage}
/>
{/* <DataTableFilterBox
filterKey="type"
title="Estado"
options={TYPE_OPTIONS}
setFilterValue={setTypeFilter}
filterValue={typeFilter}
/> */}
</div>
);
}

View File

@@ -0,0 +1,290 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@repo/shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@repo/shadcn/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { Input } from '@repo/shadcn/input';
import { useForm } from 'react-hook-form';
import { useUpdateProduct } from "@/feactures/inventory/hooks/use-mutation";
import { updateInventory, EditInventory, InventoryTable } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad
import { Textarea } from '@repo/shadcn/components/ui/textarea';
import {STATUS} from '@/constants/status'
import { useState, useEffect } from 'react';
import {sizeFormate} from "@/feactures/inventory/utils/sizeFormate"
// import { z } from 'zod'; // Asegúrate de importar Zod
// --- MODIFICACIÓN CLAVE ---
// Extiende tu esquema para incluir el campo de imagen como FileList para el frontend
// En el esquema Zod principal (editInventory), urlImg puede seguir siendo string[] si es lo que guardas en DB.
// Pero para la validación del formulario temporalmente, necesitamos manejar FileList.
// Si tu EditInventory original no contempla FileList, crea un esquema para el formulario.
// Ejemplo de cómo podrías adaptar tu esquema para el formulario
// const formSchemaWithFiles = editInventory.extend({
// urlImg: z.custom<FileList | undefined | null>().optional(), // Ahora permite FileList para el input file
// });
// Define un tipo para los datos del formulario que incluye el FileList
// type FormDataWithFiles = z.infer<typeof formSchemaWithFiles>;
interface UpdateFormProps {
onSuccess?: () => void;
onCancel?: () => void;
defaultValues?: Partial<InventoryTable>;
}
export function UpdateForm({
onSuccess,
onCancel,
defaultValues,
}: UpdateFormProps) {
const {
mutate: saveAccountingAccounts,
isPending: isSaving,
isError,
} = useUpdateProduct();
const [sizeFile, setSizeFile] = useState('0 bytes');
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
useEffect(() => {
return () => {
previewUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [previewUrls]);
const defaultformValues: EditInventory = { // Usamos el nuevo tipo aquí
id: defaultValues?.id,
title: defaultValues?.title || '',
description: defaultValues?.description || '',
price: defaultValues?.price || '',
address: defaultValues?.address || '',
status: defaultValues?.status || 'BORRADOR',
stock: defaultValues?.stock ?? 0,
urlImg: undefined, // Inicializamos como undefined o null para el FileList
};
const form = useForm<EditInventory>({ // Usamos el nuevo tipo aquí
resolver: zodResolver(updateInventory), // Usamos el esquema extendido
defaultValues: defaultformValues,
mode: 'onChange',
});
const onSubmit = async (data: EditInventory) => {
// --- MODIFICACIÓN CLAVE: Crear FormData ---
const formData = new FormData();
// Añadir otros campos de texto al FormData
formData.append('id', data.id ? String(data.id) : ''); // Los IDs a menudo son numéricos, conviértelos a string
formData.append('title', data.title);
formData.append('description', data.description);
formData.append('price', String(data.price)); // Convertir a string
formData.append('address', data.address);
formData.append('status', data.status);
formData.append('stock', String(data.stock)); // Convertir a string
// --- MODIFICACIÓN AQUÍ: Asegurar que cada archivo sea un 'File' ---
if (data.urlImg) { // Primero, verifica que FileList no sea null/undefined
for (let i = 0; i < data.urlImg.length; i++) {
const file = data.urlImg[i];
if (file) { // Asegura que el archivo individual no sea undefined
formData.append('urlImg', file); // 'file' es de tipo File, que es un Blob
}
}
}
// --- IMPORTANTE: Tu hook `useUpdateProduct` DEBE ser capaz de aceptar FormData ---
// Si `useUpdateProduct` llama a `safeFetchApi`, entonces `safeFetchApi` ya está preparado
// para recibir `FormData`.
saveAccountingAccounts(formData as any, { // Forzamos el tipo a 'any' si `useUpdateProduct` no espera FormData en su tipo
onSuccess: () => {
form.reset();
onSuccess?.();
},
onError: (error) => {
console.error("Error al guardar el producto:", error);
form.setError('root', {
type: 'manual',
message: error.message || 'Error al guardar el producto',
});
},
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{form.formState.errors.root && (
<div className="text-destructive text-sm">
{form.formState.errors.root.message}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre/Título</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem >
<FormLabel>Precio</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className='col-span-2'>
<FormLabel>Dirección</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className='col-span-2'>
<FormLabel>Descripción</FormLabel>
<FormControl>
<Textarea {...field} className="resize-none"/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stock"
render={({ field }) => (
<FormItem>
<FormLabel>Cantidad/Stock</FormLabel>
<FormControl>
<Input {...field} type="number" onChange={(e) => field.onChange(Number(e.target.value))}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Estatus</FormLabel>
<Select value={field.value} onValueChange={(value) => field.onChange(value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Seleccione un estatus" />
</SelectTrigger>
<SelectContent>
{Object.entries(STATUS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="col-span-2">
<FormField
control={form.control}
name="urlImg"
render={({ field: { onChange, onBlur, name, ref } }) => (
<FormItem>
<FormLabel>Imagen</FormLabel>
<p>Peso máximo: 5MB / {sizeFile} <span className='text-xs text-destructive'>(Máximo 10 archivos)</span></p>
<FormControl>
<Input
type="file"
multiple
onBlur={onBlur}
name={name}
ref={ref}
onChange={(e) => {
if (e.target.files) {
const files = Array.from(e.target.files);
let size = 0;
const newPreviewUrls: string[] = [];
files.forEach(element => {
size += element.size;
newPreviewUrls.push(URL.createObjectURL(element));
});
const tamañoFormateado = sizeFormate(size);
setSizeFile(tamañoFormateado);
setPreviewUrls(newPreviewUrls);
onChange(e.target.files);
} else {
setPreviewUrls([]);
}
}}
/>
</FormControl>
<FormMessage />
{previewUrls.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{previewUrls.map((url, index) => (
<img key={index} src={url} alt={`Preview ${index}`} className="w-24 h-24 object-cover rounded-md" />
))}
</div>
)}
</FormItem>
)}
/>
</div>
</div>
<div className="flex justify-end gap-4">
<Button variant="outline" type="button" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
// import { useRouter } from 'next/navigation';
import { Button } from '@repo/shadcn/button';
import { Heading } from '@repo/shadcn/heading';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AccountPlanModal } from './inventory-modal';
export function UsersHeader() {
const [open, setOpen] = useState(false);
// const router = useRouter();
return (
<>
<div className="flex items-start justify-between">
<Heading
title="Mi inventario"
description="Gestione aquí los productos que registre en la plataforma"
/>
<Button onClick={() => setOpen(true)} size="sm">
<Plus className="h-4 w-4" /><span className='hidden md:inline'>Agregar Producto</span>
</Button>
</div>
<AccountPlanModal open={open} onOpenChange={setOpen} />
</>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useRouter } from 'next/navigation';
import { useAllProductInfiniteQuery } from '@/feactures/inventory/hooks/use-query-products';
import { ProductCard } from '@/feactures/inventory/components/products/productCard';
// import { allProducts } from '@/feactures/inventory/schemas/inventory';
import { useRef, useEffect, useState } from 'react';
import { Input } from '@repo/shadcn/components/ui/input';
import { Button } from '@repo/shadcn/components/ui/button';
export function ProductList() {
const router = useRouter();
const lastProductRef = useRef(null);
const [search, setSearch] = useState("")
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useAllProductInfiniteQuery(search);
useEffect(() => {
if (!lastProductRef.current || !hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
fetchNextPage();
}
},
{
root: null,
rootMargin: '200px',
threshold: 1.0,
}
);
observer.observe(lastProductRef.current);
return () => {
observer.disconnect();
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Funcion al hacer click en un producto
const goTo = (id: number) => {
router.push(`/dashboard/productos/${id}`);
};
// funcion para el buscador
const formSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formdata = new FormData(e.currentTarget)
setSearch(formdata.get('search') as string)
console.log('submit')
}
const products = data?.pages.flatMap(page => page.data) || [];
return (
<div className="w-full grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<form onSubmit={formSubmit} action={''} className='col-span-full text-center py-3 flex gap-3'>
<Input name='search' type='text' placeholder='Buscar...' className='' defaultValue={search}/>
<Button variant={'outline'} className=''>Buscar</Button>
</form>
{isLoading ? (
<section className="col-span-full text-center py-10">
<p className="text-muted-foreground">Cargando productos...</p>
</section>
) : products.length === 0 ? (
<section className="col-span-full text-center py-10">
<p className="text-muted-foreground">No hay productos disponibles en este momento.</p>
</section>
) : (
<>
{products.map((item, index) => {
const isLastElement = index === products.length - 1;
return (
<div ref={isLastElement ? lastProductRef : null} key={item.id}>
<ProductCard product={item} onClick={() => goTo(Number(item.id))} />
</div>
);
})}
{isFetchingNextPage && (
<section className="col-span-full text-center py-10">
<p className="text-muted-foreground">Cargando más productos...</p>
</section>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@repo/shadcn/card';
import { allProducts } from '../../schemas/inventory';
interface cardProps {
product: allProducts;
onClick?: () => void;
}
export function ProductCard({ product, onClick }: cardProps) {
return (
<Card className="cursor-pointer flex flex-col" onClick={onClick}>
<CardTitle className="text-base font-bold truncate p-3 text-primary">
{product.title.charAt(0).toUpperCase() + product.title.slice(1)}
</CardTitle>
<CardContent className="p-0 flex-grow">
<img
className="object-cover w-full h-full aspect-square border"
src={`/uploads/inventory/${product.userId}/${product.id}/${product.urlImg}`}
alt=""
/>
</CardContent>
<CardFooter className="flex-col items-start p-4">
{product.status === 'AGOTADO' ? (
<p className="font-semibold text-lg text-red-900">AGOTADO</p>
) : ('')}
<p className="font-semibold text-lg">$ {product.price}</p>
</CardFooter>
</Card>
)
}

View File

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

View File

@@ -0,0 +1,35 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
// import { EditInventory } from "../schemas/inventory";
import { updateProductAction, createProductAction,deleteProductAction } from "../actions/actions";
// Create mutation
export function useCreateProduct() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: any) => createProductAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product'] }),
})
return mutation
}
// Update mutation
export function useUpdateProduct() {
const queryClient = useQueryClient();
const mutation = useMutation({
// mutationFn: (data: EditInventory) => updateUserAction(data),
mutationFn: (data: any) => updateProductAction(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product'] }),
onError: (e) => console.error('Error:', e)
})
return mutation;
}
// Delete mutation
export function useDeleteProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteProductAction(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product'] }),
onError: (e) => console.error('Error:', e)
})
}

View File

@@ -0,0 +1,36 @@
'use client'
import { useSafeQuery, useSafeInfiniteQuery } from "@/hooks/use-safe-query";
import { getInventoryAction, getAllProducts } from "../actions/actions";
// import { useInfiniteQuery } from "@tanstack/react-query";
interface Params {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
// Hook for users
export function useProductQuery(params: Params = {}) {
return useSafeQuery(['product',params], () => getInventoryAction(params))
}
export function useAllProductQuery(params: Params = {}) {
return useSafeQuery(['product',params], () => getAllProducts(params))
}
export function useAllProductInfiniteQuery(search: string = '') {
return useSafeInfiniteQuery(
['product', search],
// pageParam + 1 para evitar duplicación de datos
({ pageParam = 0 }) => getAllProducts({ page: pageParam + 1, limit: 10, search }),
(lastPage, allPages) => {
// Esta lógica determina el 'pageParam' para la siguiente página
const nextPage = allPages.length;
// Puedes añadir una condición para saber si hay más páginas
if (lastPage.data.length < 10) return undefined;
return nextPage;
}
)
}

View File

@@ -0,0 +1,19 @@
export const ACCOUNT_TYPES = {
activo: 'Activo',
pasivo: 'Pasivo',
patrimonio: 'Patrimonio',
ingreso: 'Ingreso',
gasto: 'Gasto',
costo: 'Costo',
cuenta_orden: 'Cuenta de Orden',
} as const;
export const ACCOUNT_LEVELS = {
1: 'Nivel 1 - Cuenta Principal',
2: 'Nivel 2 - Subcuenta',
3: 'Nivel 3 - Cuenta Detallada',
4: 'Nivel 4 - Cuenta Auxiliar',
} as const;
export type AccountType = keyof typeof ACCOUNT_TYPES;
export type AccountLevel = keyof typeof ACCOUNT_LEVELS;

View File

@@ -0,0 +1,83 @@
import { z } from 'zod';
export const accountPlanSchema = z
.object({
id: z.number().optional(),
savingBankId: z.number(),
code: z
.string()
.min(1, 'El código es requerido')
.max(50, 'El código no puede tener más de 50 caracteres')
.regex(/^[\d.]+$/, 'El código debe contener solo números y puntos'),
name: z
.string()
.min(1, 'El nombre es requerido')
.max(100, 'El nombre no puede tener más de 100 caracteres'),
type: z.enum(
[
'activo',
'pasivo',
'patrimonio',
'ingreso',
'gasto',
'costo',
'cuenta_orden',
],
{
required_error: 'El tipo de cuenta es requerido',
invalid_type_error: 'Tipo de cuenta inválido',
},
),
description: z.string().optional().nullable(),
level: z
.number()
.min(1, 'El nivel debe ser mayor a 0')
.max(4, 'El nivel no puede ser mayor a 4'),
parent_account_id: z.number().nullable(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
})
.refine(
(data) => {
if (data.level > 1 && !data.parent_account_id) {
return false;
}
return true;
},
{
message: 'Las cuentas de nivel superior a 1 requieren una cuenta padre',
path: ['parent_account_id'],
},
);
export type AccountPlan = z.infer<typeof accountPlanSchema>;
// Response schemas for the API
export const accountPlanResponseSchema = z.object({
message: z.string(),
data: accountPlanSchema,
});
export const accountPlanDeleteResponseSchema = z.object({
message: z.string(),
});
export const accountPlanListResponseSchema = z.object({
message: z.string(),
data: z.array(accountPlanSchema),
});
export const accountPlanPaginationResponseSchema = z.object({
message: z.string(),
data: z.array(accountPlanSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
totalCount: z.number(),
totalPages: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
nextPage: z.number().nullable(),
previousPage: z.number().nullable(),
}),
});

View File

@@ -0,0 +1,142 @@
// import { user } from '@/feactures/auth/schemas/register';
// import { all } from 'axios';
import { url } from 'inspector';
import { z } from 'zod';
export type InventoryTable = z.infer<typeof seeProduct>;
export type EditInventory = z.infer<typeof updateInventory>;
export type CreateInventory = z.infer<typeof createProduct>;
export type ProductApiResponseSchema = z.infer<typeof productApiResponseSchema>;
export type allProducts = z.infer<typeof productDetails>;
const MAX_FILE_SIZE = 5242880; // 5MB en bytes
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
const MAX_FILENAME_LENGTH = 50;
export const product = z.object({
id: z.number().optional(),
title: z.string(),
description: z.string(),
address: z.string(),
stock: z.number(),
price: z.string(),
urlImg: z.custom<FileList | undefined>().optional(),
gallery: z.array(z.string()).optional(),
status: z.string(),
userId: z.number().optional()
})
export const seeProduct = product.extend({
urlImg: z.string(),
})
export const productDetails = seeProduct.extend({
fullname: z.string(),
phone: z.string().nullable(),
email: z.string().email().nullable()
})
const validateProduct = z.object({
id: z.number().optional(),
title: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }),
description: z.string().min(10, { message: "Debe de tener 10 o más caracteres" }),
stock: z.number(),
address: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }),
price: z.string().min(1, { message: "Debe de tener 1 o más caracteres" }),
urlImg: z.custom<FileList | undefined>(),
status: z.string().min(1, { message: "Debe de seleccionar un valor" }),
userId: z.number().optional(),
})
export const updateInventory = validateProduct.extend({
urlImg: z.custom<FileList | undefined>()
.refine((files) => (files && files.length <= 10) || files === undefined, "Máximo 10 imágenes")
.refine((files) =>
// (files && Array.from(files).every(file => file.size <= MAX_FILE_SIZE)) || files === undefined
{
if (files) {
let size = 0;
Array.from(files).map(file => {
size += file.size;
})
if (size <= MAX_FILE_SIZE) return true;
return false
}
return true
}
,
`El tamaño máximo entre toda las imagenes es de 5MB`
).refine((files) =>
(files && Array.from(files).every(file => ACCEPTED_IMAGE_TYPES.includes(file.type))) || files === undefined,
"Solo se aceptan archivos .jpg, .jpeg, .png y .webp"
).refine((files) =>
(files && Array.from(files).every(file => file.name.length <= MAX_FILENAME_LENGTH)) || files === undefined,
`El nombre de cada archivo no puede superar los ${MAX_FILENAME_LENGTH} caracteres`
),
})
export const createProduct = validateProduct.extend({
urlImg: z.custom<FileList | undefined>()
.refine((files) => files && files.length > 0, "Se requiere al menos una imagen")
.refine((files) => files && files.length <= 10, "Máximo 10 imágenes")
.refine((files) => {
let size = 0;
if (files) Array.from(files).map(file => {
size += file.size;
})
if (size <= MAX_FILE_SIZE) return true;
return false
},
`El tamaño máximo entre toda las imagenes es de 5MB`
).refine((files) =>
files && Array.from(files).every(file => ACCEPTED_IMAGE_TYPES.includes(file.type)),
"Solo se aceptan archivos .jpg, .jpeg, .png y .webp"
).refine((files) =>
files && Array.from(files).every(file => file.name.length <= MAX_FILENAME_LENGTH),
`El nombre de cada archivo no puede superar los ${MAX_FILENAME_LENGTH} caracteres`
),
})
export const ApiResponseSchema = z.object({
message: z.string(),
data: z.array(seeProduct),
meta: z.object({
page: z.number(),
limit: z.number(),
totalCount: z.number(),
totalPages: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
nextPage: z.number().nullable(),
previousPage: z.number().nullable(),
}),
})
export const productApiResponseSchema = z.object({
message: z.string(),
data: z.array(productDetails),
meta: z.object({
page: z.number(),
limit: z.number(),
totalCount: z.number(),
totalPages: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
nextPage: z.number().nullable(),
previousPage: z.number().nullable(),
}),
})
export const productMutate = z.object({
message: z.string(),
data: seeProduct,
})
export const getProduct = z.object({
message: z.string(),
data: productDetails,
})
export const deleteProduct = z.object({
message: z.string(),
})

View File

@@ -0,0 +1,6 @@
export const PUBLISHED_TYPES = {
published: 'Publicada',
draft: 'Borrador',
} as const;
export type PublishedType = keyof typeof PUBLISHED_TYPES;

View File

@@ -0,0 +1,11 @@
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}

View File

@@ -0,0 +1,16 @@
import {
createSearchParamsCache,
createSerializer,
parseAsInteger,
parseAsString,
} from 'nuqs/server';
export const searchParams = {
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(10),
q: parseAsString,
type: parseAsString,
};
export const searchParamsCache = createSearchParamsCache(searchParams);
export const serialize = createSerializer(searchParams);

View File

@@ -0,0 +1,13 @@
export const sizeFormate = (size: number) => {
let tamañoFormateado = '';
if (size < 1024) {
tamañoFormateado = size + ' bytes';
} else if (size < 1024 * 1024) {
tamañoFormateado = (size / 1024).toFixed(2) + ' KB';
} else if (size < 1024 * 1024 * 1024) {
tamañoFormateado = (size / (1024 * 1024)).toFixed(2) + ' MB';
} else {
tamañoFormateado = (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
return tamañoFormateado;
}

View File

@@ -0,0 +1,55 @@
import { Button } from '@repo/shadcn/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@repo/shadcn/card';
import { Badge } from '@repo/shadcn/badge';
import { BadgeCheck } from 'lucide-react';
import { SurveyAnswerForUser } from '../schemas/survey';
interface cardProps {
data: SurveyAnswerForUser;
onClick: (id: number) => void;
}
export function SurveyCard ({ data, onClick }: cardProps) {
return (
<Card key={data.surveys.id} className="flex flex-col">
<CardHeader>
<CardTitle>{data.surveys.title}</CardTitle>
<CardDescription>{data.surveys.description}</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<section className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Fecha de creación:</span>
<span>{new Date(data.surveys.created_at).toLocaleDateString()}</span>
</div>
{data.surveys.closingDate && (
<div className="flex justify-between">
<span className="text-muted-foreground">Fecha de cierre:</span>
<span>{new Date(data.surveys.closingDate).toLocaleDateString()}</span>
</div>
)}
</section>
</CardContent>
<CardFooter className="flex justify-center">
{data.answers_surveys === null ? (
<Button className="w-full" onClick={() => onClick(Number(data.surveys.id))}>
Responder
</Button>
) : (
<Badge className="px-4 py-2 w-full bg-green-600 text-black">
<BadgeCheck size={28} />
Realizada
</Badge>
)}
</CardFooter>
</Card>
)
}

View File

@@ -4,83 +4,99 @@
// - Permite editar encuestas existentes
// - Permite eliminar encuestas con confirmación
// - Muestra el estado (publicada/borrador), fechas y conteo de respuestas
'use client';
import { Button } from '@repo/shadcn/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@repo/shadcn/card';
import { useRouter } from 'next/navigation';
import { useSurveysForUserQuery } from '@/feactures/surveys/hooks/use-query-surveys';
import { SurveyAnswerForUser } from '../schemas/survey';
import { Badge } from '@repo/shadcn/badge';
import { BadgeCheck } from 'lucide-react';
import { useAllSurveysInfiniteQuery } from '@/feactures/surveys/hooks/use-query-surveys';
import { SurveyCard } from '@/feactures/surveys/components/survey-card';
import { SurveyAnswerForUser } from '../schemas/survey';
import { useEffect, useRef, useState } from 'react';
import { Input } from '@repo/shadcn/components/ui/input';
export function SurveyList() {
const router = useRouter();
const {data: surveys} = useSurveysForUserQuery()
const lastProductRef = useRef(null);
const [search, setSearch] = useState("")
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useAllSurveysInfiniteQuery(search)
useEffect(() => {
if (!lastProductRef.current || !hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
fetchNextPage();
}
},
{
root: null,
rootMargin: '200px',
threshold: 1.0,
}
);
observer.observe(lastProductRef.current);
return () => {
observer.disconnect();
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const surveys = data?.pages.flatMap(page => page.data) || [];
// funcion para el buscador
const formSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formdata = new FormData(e.currentTarget)
setSearch(formdata.get('search') as string)
// console.log('submit')
}
const handleRespond = (surveyId: number) => {
router.push(`/dashboard/encuestas/${surveyId}/responder`);
};
// console.log(surveys?.data)
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{surveys?.meta.totalPages === 0 ? (
<div className="col-span-full text-center py-10">
<form onSubmit={formSubmit} action={''} className='col-span-full text-center py-3 flex gap-3'>
<Input name='search' type='text' placeholder='Buscar...' className='' defaultValue={search}/>
<Button variant={'outline'} className=''>Buscar</Button>
</form>
{isLoading ? (
<section className="col-span-full text-center py-10">
<p className="text-muted-foreground">Cargando productos...</p>
</section>
) : surveys.length === 0 ? (
<section className="col-span-full text-center py-10">
<p className="text-muted-foreground">No hay encuestas disponibles en este momento.</p>
</div>
</section>
) : (
surveys?.data.map((data: SurveyAnswerForUser) => (
<Card key={data.surveys.id} className="flex flex-col">
<CardHeader>
<CardTitle>{data.surveys.title}</CardTitle>
<CardDescription>{data.surveys.description}</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Fecha de creación:</span>
{/* <span>{data.surveys.created_at.toLocaleDateString()}</span> */}
<span>{new Date(data.surveys.created_at).toLocaleDateString()}</span>
</div>
{data.surveys.closingDate && (
<div className="flex justify-between">
<span className="text-muted-foreground">Fecha de cierre:</span>
{/* <span>{data.surveys.closingDate.toLocaleDateString()}</span> */}
<span>{new Date(data.surveys.closingDate).toLocaleDateString()}</span>
</div>
)}
<>
{surveys.map((data: SurveyAnswerForUser, index) => {
const isLastElement = index === surveys.length - 1;
return (
<div ref={isLastElement ? lastProductRef : null} key={data.surveys.id}>
<SurveyCard data={data} onClick={handleRespond}/>
</div>
</CardContent>
<CardFooter className="flex justify-center">
{data.answers_surveys === null ? (
<Button
className="w-full"
onClick={() => handleRespond(Number(data.surveys.id))}
>
Responder
</Button>
) : (
<Badge className="px-4 py-2 w-full bg-green-600 text-black">
<BadgeCheck size={28} />
Realizada
</Badge>
)}
</CardFooter>
</Card>
))
)
})}
{isFetchingNextPage && (
<section className="col-span-full text-center py-10">
<p className="text-muted-foreground">Cargando más productos...</p>
</section>
)}
</>
)}
</div>
);
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import { useSafeQuery } from "@/hooks/use-safe-query";
import { useSafeInfiniteQuery, useSafeQuery } from "@/hooks/use-safe-query";
import { getSurveyByIdAction, getSurveysAction, getSurveysForUserAction } from "../actions/surveys-actions";
@@ -8,13 +8,25 @@ export function useSurveysQuery(params = {}) {
return useSafeQuery(['surveys',params], () => getSurveysAction(params))
}
export function useAllSurveysInfiniteQuery(search: string = '') {
return useSafeInfiniteQuery(
['surveys', search],
// pageParam + 1 para evitar duplicación de datos
({ pageParam = 0 }) => getSurveysForUserAction({ page: pageParam + 1, limit: 10, search }),
(lastPage, allPages) => {
// Esta lógica determina el 'pageParam' para la siguiente página
const nextPage = allPages.length;
// Puedes añadir una condición para saber si hay más páginas
if (lastPage.data.length < 10) return undefined;
return nextPage;
}
)
}
export function useSurveysForUserQuery(params = {}) {
return useSafeQuery(['surveys',params], () => getSurveysForUserAction(params))
}
export function useSurveysByIdQuery(id: number) {
return useSafeQuery(['surveys',id], () => getSurveyByIdAction(id))
}

View File

@@ -59,7 +59,7 @@ export function CreateUserForm({
confirmPassword: '',
id: defaultValues?.id,
phone: defaultValues?.phone || '',
role: defaultValues?.role,
role: undefined,
}
const form = useForm<CreateUser>({
@@ -68,10 +68,9 @@ export function CreateUserForm({
mode: 'onChange', // Enable real-time validation
});
const onSubmit = async (data: CreateUser) => {
const formData = data
const onSubmit = async (formData: CreateUser) => {
console.log(formData);
saveAccountingAccounts(formData, {
onSuccess: () => {
form.reset();
@@ -185,10 +184,7 @@ export function CreateUserForm({
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Rol</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
>
<Select onValueChange={(value) => field.onChange(Number(value))}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona un rol" />

View File

@@ -171,9 +171,7 @@ export function UpdateUserForm({
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Rol</FormLabel>
<Select onValueChange={(value) => field.onChange(Number(value))}
// defaultValue={String(field.value)}
>
<Select onValueChange={(value) => field.onChange(Number(value))}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona un rol" />

View File

@@ -1,83 +0,0 @@
// 'use client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@repo/shadcn/form';
interface SelectListProps {
label: string
// values:
values: Array<object>
form: any
name: string
handleChange: any
}
export function SelectList({ label, values, form, name, handleChange }: SelectListProps) {
// const { label, values, form, name } = props;
// handleChange
// const defaultformValues = {
// username: '',
// fullname: '',
// email: '',
// password: '',
// id: 0,
// phone: '',
// role: undefined,
// isActive: false
// }
// const form = useForm<UpdateUser>({
// resolver: zodResolver(updateUser),
// defaultValues: defaultformValues,
// mode: 'onChange', // Enable real-time validation
// });
return <FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>{label}</FormLabel>
<Select onValueChange={handleChange}
// defaultValue={String(field.value)}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona una opción" />
</SelectTrigger>
</FormControl>
<SelectContent className="w-full min-w-[200px]">
{values.map((item: any) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
{/* <SelectItem key={0} value="0">Hola1</SelectItem>
<SelectItem key={1} value="1">Hola2</SelectItem> */}
{/* {Object.entries(values).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))} */}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
}

View File

@@ -1,28 +0,0 @@
'use client'
import { SurveyResponse } from '@/feactures/surveys/components/survey-response';
import { useSurveysByIdQuery } from '@/feactures/surveys/hooks/use-query-surveys';
import { notFound, useParams } from 'next/navigation';
export default function SurveyPage() {
const params = useParams();
const surveyId = params?.id as string | undefined;
if (!surveyId || surveyId === '') {
notFound();
}
const { data: survey, isLoading } = useSurveysByIdQuery(Number(surveyId));
console.log('🎯 useSurveysByIdQuery ejecutado, data:', survey, 'isLoading:', isLoading);
if (!survey?.data || !survey?.data.published) {
notFound();
}
return (
<SurveyResponse survey={survey?.data} />
);
}

View File

@@ -1,4 +1,5 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { UseQueryOptions, useInfiniteQuery, useQuery } from '@tanstack/react-query'
export function useSafeQuery<T, K = unknown>(
queryKey: [string, K?],
@@ -10,4 +11,17 @@ export function useSafeQuery<T, K = unknown>(
queryFn,
...options,
})
}
export function useSafeInfiniteQuery<T, K = unknown>(
queryKey: [string, K?],
queryFn: ({ pageParam }: { pageParam: number }) => Promise<T>,
getNextPageParam: (lastPage: T, allPages: T[]) => number | undefined,
) {
return useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam,
initialPageParam: 0,
})
}

View File

@@ -52,7 +52,7 @@ const authConfig: NextAuthConfig = {
password: credentials?.password as string,
};
// Asigna el tipo `SignInActionResult` que ahora incluye `null`
// Asigna el tipo `SignInActionResult` que ahora incluye `null`
const response: SignInActionResult = await SignInAction(credential);
// **NUEVO: Manejar el caso `null` primero**
@@ -69,15 +69,15 @@ const authConfig: NextAuthConfig = {
response.type === 'UNKNOWN_ERROR') // Incluye todos los tipos de error posibles
) {
// Si es un error, lánzalo. Este camino termina aquí.
throw new CredentialsSignin(response.message);
throw new CredentialsSignin("Error en la API:" + response.message);
}
if (!('user' in response)) {
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null,
// no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
// Es un caso de respuesta inesperada del API.
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.");
throw new CredentialsSignin("Error en el formato de la respuesta del servidor.");
if (!('user' in response)) {
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null,
// no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
// Es un caso de respuesta inesperada del API.
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.");
throw new CredentialsSignin("Error en el formato de la respuesta del servidor.");
}
return {
@@ -91,7 +91,7 @@ const authConfig: NextAuthConfig = {
refresh_token: response?.tokens.refresh_token ?? '',
refresh_expire_in: response?.tokens.refresh_expire_in ?? 0,
};
},
}),
@@ -100,50 +100,77 @@ const authConfig: NextAuthConfig = {
signIn: '/', //sigin page
},
callbacks: {
async jwt({
token,
user
}: {
token: any;
user: User;
async jwt({ token, user }:{
user: User
token: any
}) {
// Si es un nuevo login, asignamos los datos
// 1. Manejar el inicio de sesión inicial
// El `user` solo se proporciona en el primer inicio de sesión.
if (user) {
token.id = user.id;
token.username = user.username;
token.fullname = user.fullname;
token.email = user.email;
token.role = user.role;
token.access_token = user.access_token;
token.access_expire_in = user.access_expire_in;
token.refresh_token = user.refresh_token;
token.refresh_expire_in = user.refresh_expire_in;
return {
id: user.id,
username: user.username,
fullname: user.fullname,
email: user.email,
role: user.role,
access_token: user.access_token,
access_expire_in: user.access_expire_in,
refresh_token: user.refresh_token,
refresh_expire_in: user.refresh_expire_in
}
// return token;
}
// Renovar access_token si ha expirado
if (Date.now() / 1000 > (token.access_expire_in as number)) {
if (Date.now() / 1000 > (token.refresh_expire_in as number)) {
return null; // Forzar logout
}
// 2. Si no es un nuevo login, verificar la expiración del token
const now = Math.floor(Date.now() / 1000); // Usar Math.floor para un número entero
try {
const res = await resfreshTokenAction({
token: token.refresh_token as string,
});
if (!res) throw new Error('Failed to refresh token');
token.access_token = res.tokens.access_token;
token.access_expire_in = res.tokens.access_expire_in;
token.refresh_token = res.tokens.refresh_token;
token.refresh_expire_in = res.tokens.refresh_expire_in;
} catch (error) {
console.log(error);
return null;
}
// Verificar si el token de acceso aún es válido
if (now < (token.access_expire_in as number)) {
return token; // Si no ha expirado, no hacer nada y devolver el token actual
}
return token;
// console.log("Now Access Expire:",token.access_expire_in);
// 3. Si el token de acceso ha expirado, verificar el refresh token
// console.log("Access token ha expirado. Verificando refresh token...");
if (now > (token.refresh_expire_in as number)) {
// console.log("Refresh token ha expirado. Forzando logout.");
return null; // Forzar el logout al devolver null
}
// console.log("token:", token.refresh_token);
// 4. Si el token de acceso ha expirado pero el refresh token es válido, renovar
// console.log("Renovando token de acceso...");
try {
const res = await resfreshTokenAction({ token: token.refresh_token as string });
if (!res || !res.tokens) {
throw new Error('Fallo en la respuesta de la API de refresco.');
}
// console.log("Old Access Expire:", token.access_expire_in);
// console.log("New Access Expire:", res.tokens.access_expire_in);
// console.log("token:", token.refresh_token);
// Actualizar el token directamente con los nuevos valores
token.access_token = res.tokens.access_token;
token.access_expire_in = res.tokens.access_expire_in;
token.refresh_token = res.tokens.refresh_token;
token.refresh_expire_in = res.tokens.refresh_expire_in;
return token;
} catch (error) {
console.error("Error al renovar el token: ", error);
return null; // Fallo al renovar, forzar logout
}
},
async session({ session, token }: { session: Session; token: DefaultJWT }) {
async session({ session, token }: { session: Session; token: any }) {
session.access_token = token.access_token as string;
session.access_expire_in = token.access_expire_in as number;
session.refresh_token = token.refresh_token as string;

View File

@@ -1,46 +1,49 @@
'use server';
import { env } from '@/lib/env'; // Importamos la configuración de entorno validada
import { env } from '@/lib/env';
import axios from 'axios';
import { z } from 'zod';
// Crear instancia de Axios con la URL base validada
const fetchApi = axios.create({
baseURL: env.API_URL, // Aquí usamos env.API_URL en vez de process.env.BACKEND_URL
headers: {
'Content-Type': 'application/json',
},
baseURL: env.API_URL,
});
// Interceptor para incluir el token automáticamente en las peticiones
// ESTE INTERCEPTOR ESTÁ BIEN PARA EL RESTO DE LAS PETICIONES AUTENTICADAS
fetchApi.interceptors.request.use(async (config: any) => {
try {
// Importación dinámica para evitar la referencia circular
const { auth } = await import('@/lib/auth');
// console.log("Solicitando autenticación...");
const { auth } = await import('@/lib/auth'); // Importación dinámica
const session = await auth();
const token = session?.access_token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} catch (error) {
console.error('Error getting auth token:', error);
}
// **Importante:** Si el body es FormData, elimina el Content-Type para que Axios lo configure automáticamente.
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
} else {
config.headers['Content-Type'] = 'application/json';
}
return config;
return config;
} catch (error) {
console.error('Error al obtener el token de autenticación para el interceptor:', error);
// IMPORTANTE: Si ocurre un error aquí, es mejor rechazar la promesa
// para que la solicitud no se envíe sin autorización.
return Promise.reject(error);
}
});
/**
* Función para hacer peticiones con validación de respuesta
* @param schema - Esquema de Zod para validar la respuesta
* @param url - Endpoint a consultar
* @param config - Configuración opcional de Axios
* @returns [error, data] -> Devuelve el error como objeto estructurado si hay fallo, o los datos validados
*/
// safeFetchApi sigue siendo útil para el resto de las llamadas que requieren autenticación
export const safeFetchApi = async <T extends z.ZodSchema<any>>(
schema: T,
url: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
body?: any,
data?: any,
): Promise<
[{ type: string; message: string; details?: any } | null, z.infer<T> | null]
> => {
@@ -48,7 +51,7 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
const response = await fetchApi({
method,
url,
data: body,
data,
});
const parsed = schema.safeParse(response.data);
@@ -60,7 +63,6 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
expectedSchema: schema,
data: response.data.data,
});
// console.error(parsed.error.errors)
return [
{
type: 'VALIDATION_ERROR',

View File

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

View File

@@ -0,0 +1,11 @@
import axios from 'axios';
import { env } from '@/lib/env'; // Asegúrate de que env está correctamente configurado
// Crea una instancia de Axios SÓLO para la API de refresh token
// Sin el interceptor que obtiene la sesión para evitar la dependencia circular
export const refreshApi = axios.create({
baseURL: env.API_URL,
headers: {
'Content-Type': 'application/json', // El refresh token se envía en el body JSON
},
});

View File

@@ -1,4 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '5mb',
},
}
};
export default nextConfig;