Merge branch 'inventory'
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,3 +37,5 @@ yarn-error.log*
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
apps/web/public/uploads
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
1441
apps/api/src/database/migrations/meta/0002_snapshot.json
Normal file
1441
apps/api/src/database/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1498
apps/api/src/database/migrations/meta/0003_snapshot.json
Normal file
1498
apps/api/src/database/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1516
apps/api/src/database/migrations/meta/0004_snapshot.json
Normal file
1516
apps/api/src/database/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1522
apps/api/src/database/migrations/meta/0005_snapshot.json
Normal file
1522
apps/api/src/database/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1537
apps/api/src/database/migrations/meta/0006_snapshot.json
Normal file
1537
apps/api/src/database/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1550
apps/api/src/database/migrations/meta/0007_snapshot.json
Normal file
1550
apps/api/src/database/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
40
apps/api/src/database/schema/inventory.ts
Normal file
40
apps/api/src/database/schema/inventory.ts
Normal 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`);
|
||||
@@ -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) {
|
||||
|
||||
42
apps/api/src/database/seeds/inventory.seed.ts
Normal file
42
apps/api/src/database/seeds/inventory.seed.ts
Normal 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');
|
||||
}
|
||||
@@ -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';
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,7 @@ export class RefreshTokenDto {
|
||||
})
|
||||
refresh_token: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
user_id: number;
|
||||
// @ApiProperty()
|
||||
// @IsNumber()
|
||||
// user_id: number;
|
||||
}
|
||||
|
||||
48
apps/api/src/features/inventory/dto/create-product.dto.ts
Normal file
48
apps/api/src/features/inventory/dto/create-product.dto.ts
Normal 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;
|
||||
}
|
||||
40
apps/api/src/features/inventory/dto/update-product.dto.ts
Normal file
40
apps/api/src/features/inventory/dto/update-product.dto.ts
Normal 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;
|
||||
}
|
||||
43
apps/api/src/features/inventory/entities/inventory.entity.ts
Normal file
43
apps/api/src/features/inventory/entities/inventory.entity.ts
Normal 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;
|
||||
// }
|
||||
99
apps/api/src/features/inventory/inventory.controller.ts
Normal file
99
apps/api/src/features/inventory/inventory.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/features/inventory/inventory.module.ts
Normal file
11
apps/api/src/features/inventory/inventory.module.ts
Normal 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 {}
|
||||
321
apps/api/src/features/inventory/inventory.service.ts
Normal file
321
apps/api/src/features/inventory/inventory.service.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/api/src/features/pictures/pictures.controller.ts
Normal file
19
apps/api/src/features/pictures/pictures.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/api/src/features/pictures/pictures.module.ts
Normal file
10
apps/api/src/features/pictures/pictures.module.ts
Normal 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 {}
|
||||
41
apps/api/src/features/pictures/pictures.service.ts
Normal file
41
apps/api/src/features/pictures/pictures.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
37
apps/web/app/dashboard/inventario/page.tsx
Normal file
37
apps/web/app/dashboard/inventario/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/app/dashboard/productos/[id]/page.tsx
Normal file
28
apps/web/app/dashboard/productos/[id]/page.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
19
apps/web/app/dashboard/productos/page.tsx
Normal file
19
apps/web/app/dashboard/productos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
13
apps/web/constants/status.ts
Normal file
13
apps/web/constants/status.ts
Normal 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"
|
||||
}
|
||||
@@ -2,9 +2,6 @@
|
||||
import { safeFetchApi } from '@/lib';
|
||||
import { loginResponseSchema, UserFormValue } from '../schemas/login';
|
||||
|
||||
|
||||
|
||||
|
||||
type LoginActionSuccess = {
|
||||
message: string;
|
||||
user: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
173
apps/web/feactures/inventory/actions/actions.ts
Normal file
173
apps/web/feactures/inventory/actions/actions.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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} />,
|
||||
},
|
||||
];
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
35
apps/web/feactures/inventory/hooks/use-mutation.ts
Normal file
35
apps/web/feactures/inventory/hooks/use-mutation.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
36
apps/web/feactures/inventory/hooks/use-query-products.ts
Normal file
36
apps/web/feactures/inventory/hooks/use-query-products.ts
Normal 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;
|
||||
}
|
||||
)
|
||||
}
|
||||
19
apps/web/feactures/inventory/schemas/account-plan-options.ts
Normal file
19
apps/web/feactures/inventory/schemas/account-plan-options.ts
Normal 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;
|
||||
83
apps/web/feactures/inventory/schemas/account-plan.schema.ts
Normal file
83
apps/web/feactures/inventory/schemas/account-plan.schema.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
142
apps/web/feactures/inventory/schemas/inventory.ts
Normal file
142
apps/web/feactures/inventory/schemas/inventory.ts
Normal 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(),
|
||||
})
|
||||
6
apps/web/feactures/inventory/schemas/surveys-options.ts
Normal file
6
apps/web/feactures/inventory/schemas/surveys-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const PUBLISHED_TYPES = {
|
||||
published: 'Publicada',
|
||||
draft: 'Borrador',
|
||||
} as const;
|
||||
|
||||
export type PublishedType = keyof typeof PUBLISHED_TYPES;
|
||||
11
apps/web/feactures/inventory/utils/date-utils.ts
Normal file
11
apps/web/feactures/inventory/utils/date-utils.ts
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
16
apps/web/feactures/inventory/utils/searchparams.ts
Normal file
16
apps/web/feactures/inventory/utils/searchparams.ts
Normal 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);
|
||||
13
apps/web/feactures/inventory/utils/sizeFormate.ts
Normal file
13
apps/web/feactures/inventory/utils/sizeFormate.ts
Normal 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;
|
||||
}
|
||||
55
apps/web/feactures/surveys/components/survey-card.tsx
Normal file
55
apps/web/feactures/surveys/components/survey-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 { useAllSurveysInfiniteQuery } from '@/feactures/surveys/hooks/use-query-surveys';
|
||||
import { SurveyCard } from '@/feactures/surveys/components/survey-card';
|
||||
|
||||
import { SurveyAnswerForUser } from '../schemas/survey';
|
||||
import { Badge } from '@repo/shadcn/badge';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
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>
|
||||
<>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
{isFetchingNextPage && (
|
||||
<section className="col-span-full text-center py-10">
|
||||
<p className="text-muted-foreground">Cargando más productos...</p>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function CreateUserForm({
|
||||
confirmPassword: '',
|
||||
id: defaultValues?.id,
|
||||
phone: defaultValues?.phone || '',
|
||||
role: defaultValues?.role,
|
||||
role: undefined,
|
||||
}
|
||||
|
||||
const form = useForm<CreateUser>({
|
||||
@@ -68,9 +68,8 @@ 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: () => {
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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?],
|
||||
@@ -11,3 +12,16 @@ export function useSafeQuery<T, K = unknown>(
|
||||
...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,
|
||||
})
|
||||
}
|
||||
@@ -69,7 +69,7 @@ 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)) {
|
||||
@@ -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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) throw new Error('Failed to refresh token');
|
||||
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;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
} 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',
|
||||
|
||||
99
apps/web/lib/fetch.api2.ts
Normal file
99
apps/web/lib/fetch.api2.ts
Normal 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 };
|
||||
11
apps/web/lib/refreshApi.ts
Normal file
11
apps/web/lib/refreshApi.ts
Normal 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
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '5mb',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background max-h-[90vh] overflow-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'border-gray-400 dark:border-input border-2 file:text-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
'border-gray-400 dark:border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function DataTable<TData, TValue>({
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="relative flex flex-1">
|
||||
<div className="relative flex flex-1 min-h-[300px]">
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 flex rounded-md border">
|
||||
<Table className="relative">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/50 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
'border-gray-400 dark:border-input placeholder:text-muted-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/50 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user