Merge branch 'inventory'
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,3 +37,5 @@ yarn-error.log*
|
|||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
apps/web/public/uploads
|
||||||
@@ -37,19 +37,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "11.0.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "16.5.0",
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "0.40.0",
|
||||||
"express": "^5.1.0",
|
"express": "5.1.0",
|
||||||
"joi": "^17.13.3",
|
"joi": "17.13.3",
|
||||||
"moment": "^2.30.1",
|
"moment": "2.30.1",
|
||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "8.2.0",
|
||||||
"pg": "^8.13.3",
|
"pg": "8.13.3",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "13.0.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "0.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@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 { RolesModule } from './features/roles/roles.module';
|
||||||
import { UserRolesModule } from './features/user-roles/user-roles.module';
|
import { UserRolesModule } from './features/user-roles/user-roles.module';
|
||||||
import { SurveysModule } from './features/surveys/surveys.module';
|
import { SurveysModule } from './features/surveys/surveys.module';
|
||||||
|
import {InventoryModule} from './features/inventory/inventory.module'
|
||||||
|
import { PicturesModule } from './features/pictures/pictures.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -58,7 +59,9 @@ import { SurveysModule } from './features/surveys/surveys.module';
|
|||||||
UserRolesModule,
|
UserRolesModule,
|
||||||
ConfigurationsModule,
|
ConfigurationsModule,
|
||||||
SurveysModule,
|
SurveysModule,
|
||||||
LocationModule
|
LocationModule,
|
||||||
|
InventoryModule,
|
||||||
|
PicturesModule
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class JwtRefreshGuard implements CanActivate {
|
|||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException('No Refresh Token?');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
request.user = await this.jwtService.verifyAsync(token, {
|
request.user = await this.jwtService.verifyAsync(token, {
|
||||||
@@ -43,7 +43,14 @@ export class JwtRefreshGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractTokenFromHeader(request: Request): string | undefined {
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
const token = request.body.refresh_token
|
||||||
return type === 'Bearer' ? token : undefined;
|
// 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/auth';
|
||||||
export * from './schema/general';
|
export * from './schema/general';
|
||||||
export * from './schema/surveys'
|
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,
|
"when": 1747665408016,
|
||||||
"tag": "0001_massive_kylun",
|
"tag": "0001_massive_kylun",
|
||||||
"breakpoints": true
|
"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 { seedParishes } from './parishes';
|
||||||
import { seedStates } from './states';
|
import { seedStates } from './states';
|
||||||
import { seedUserAdmin } from './user-admin.seed';
|
import { seedUserAdmin } from './user-admin.seed';
|
||||||
|
import {seedProducts} from './inventory.seed'
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
@@ -25,6 +26,7 @@ async function main() {
|
|||||||
await seedLocalities(db);
|
await seedLocalities(db);
|
||||||
await seedAdminRole(db);
|
await seedAdminRole(db);
|
||||||
await seedUserAdmin(db);
|
await seedUserAdmin(db);
|
||||||
|
await seedProducts(db);
|
||||||
|
|
||||||
console.log('All seeds completed successfully');
|
console.log('All seeds completed successfully');
|
||||||
} catch (error) {
|
} 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,
|
HttpCode,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -51,16 +52,31 @@ export class AuthController {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
@UseGuards(JwtRefreshGuard)
|
@UseGuards(JwtRefreshGuard)
|
||||||
@Patch('refresh-token')
|
|
||||||
//@RequirePermissions('auth:refresh-token')
|
|
||||||
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
|
||||||
return await this.authService.refreshToken(refreshTokenDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Get('test')
|
@Patch('refresh')
|
||||||
async test() {
|
//@RequirePermissions('auth:refresh-token')
|
||||||
return 'aplication test success';
|
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
|
//Refresh User Access Token
|
||||||
async refreshToken(dto: RefreshTokenDto): Promise<RefreshTokenInterface> {
|
async refreshToken(dto: RefreshTokenDto,user_id:number): Promise<RefreshTokenInterface> {
|
||||||
const { user_id } = dto;
|
// const { user_id } = dto;
|
||||||
|
// const user_id = 1;
|
||||||
|
|
||||||
const session = await this.drizzle
|
const session = await this.drizzle
|
||||||
.select()
|
.select()
|
||||||
@@ -274,16 +275,22 @@ export class AuthService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// console.log(session.length);
|
||||||
|
|
||||||
if (session.length === 0) throw new NotFoundException('session not found');
|
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');
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
// Genera token
|
||||||
const tokens = await this.generateTokens(user);
|
const tokens = await this.generateTokens(user);
|
||||||
const decodeAccess = this.decodeToken(tokens.access_token);
|
const decodeAccess = this.decodeToken(tokens.access_token);
|
||||||
const decodeRefresh = this.decodeToken(tokens.refresh_token);
|
const decodeRefresh = this.decodeToken(tokens.refresh_token);
|
||||||
|
|
||||||
|
// Actualiza session
|
||||||
await this.drizzle
|
await this.drizzle
|
||||||
.update(sessions)
|
.update(sessions)
|
||||||
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
|
.set({ sessionToken: tokens.refresh_token, expiresAt: decodeRefresh.exp })
|
||||||
.where(eq(sessions.userId, dto.user_id));
|
.where(eq(sessions.userId, user_id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class RefreshTokenDto {
|
|||||||
})
|
})
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
|
||||||
@ApiProperty()
|
// @ApiProperty()
|
||||||
@IsNumber()
|
// @IsNumber()
|
||||||
user_id: number;
|
// 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 * as schema from '@/database/index';
|
||||||
import { CreateSurveyDto } from './dto/create-survey.dto';
|
import { CreateSurveyDto } from './dto/create-survey.dto';
|
||||||
import { UpdateSurveyDto } from './dto/update-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 { SurveyDetailDto, SurveyStatisticsResponseDto } from './dto/statistics-response.dto';
|
||||||
import { PaginationDto } from '@/common/dto/pagination.dto';
|
import { PaginationDto } from '@/common/dto/pagination.dto';
|
||||||
import { AnswersSurveyDto } from './dto/response-survey.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') {
|
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);
|
// console.log(searchCondition);
|
||||||
|
|||||||
@@ -40,12 +40,11 @@ export class UsersController {
|
|||||||
@ApiResponse({ status: 201, description: 'User created successfully.' })
|
@ApiResponse({ status: 201, description: 'User created successfully.' })
|
||||||
async create(
|
async create(
|
||||||
@Body() createUserDto: CreateUserDto,
|
@Body() createUserDto: CreateUserDto,
|
||||||
@Query('roleId') roleId?: string,
|
@Query('role') role?: string,
|
||||||
) {
|
) {
|
||||||
const data = await this.usersService.create(
|
console.log(role);
|
||||||
createUserDto,
|
|
||||||
roleId ? parseInt(roleId) : undefined,
|
const data = await this.usersService.create(createUserDto)
|
||||||
);
|
|
||||||
return { message: 'User created successfully', data };
|
return { message: 'User created successfully', data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class UsersService {
|
|||||||
// Assign role to user
|
// Assign role to user
|
||||||
await tx.insert(usersRole).values({
|
await tx.insert(usersRole).values({
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
roleId: roleId,
|
roleId: createUserDto.role || roleId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the created user with role
|
// 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 {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Blocks,
|
||||||
Check,
|
Check,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -40,6 +41,7 @@ export type Icon = LucideIcon;
|
|||||||
|
|
||||||
export const Icons = {
|
export const Icons = {
|
||||||
dashboard: LayoutDashboardIcon,
|
dashboard: LayoutDashboardIcon,
|
||||||
|
blocks: Blocks,
|
||||||
logo: Command,
|
logo: Command,
|
||||||
login: LogIn,
|
login: LogIn,
|
||||||
close: X,
|
close: X,
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ export const GeneralItems: NavItem[] = [
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
items: [], // No child items
|
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 { safeFetchApi } from '@/lib';
|
||||||
import { loginResponseSchema, UserFormValue } from '../schemas/login';
|
import { loginResponseSchema, UserFormValue } from '../schemas/login';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type LoginActionSuccess = {
|
type LoginActionSuccess = {
|
||||||
message: string;
|
message: string;
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { safeFetchApi } from '@/lib';
|
import { refreshApi } from '@/lib/refreshApi'; // Importa la nueva instancia
|
||||||
import {
|
import {
|
||||||
RefreshTokenResponseSchema,
|
RefreshTokenResponseSchema,
|
||||||
RefreshTokenValue,
|
RefreshTokenValue,
|
||||||
} from '../schemas/refreshToken';
|
} from '../schemas/refreshToken';
|
||||||
|
|
||||||
export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
|
export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
|
||||||
const [error, data] = await safeFetchApi(
|
try {
|
||||||
RefreshTokenResponseSchema,
|
const response = await refreshApi.patch('/auth/refresh', {refresh_token: refreshToken.token});
|
||||||
'/auth/refreshToken',
|
|
||||||
'POST',
|
const parsed = RefreshTokenResponseSchema.safeParse(response.data);
|
||||||
refreshToken,
|
|
||||||
);
|
if (!parsed.success) {
|
||||||
if (error) {
|
console.error('Error de validación en la respuesta de refresh token:', {
|
||||||
console.error('Error:', error);
|
errors: parsed.error.errors,
|
||||||
} else {
|
receivedData: response.data,
|
||||||
return 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 editar encuestas existentes
|
||||||
// - Permite eliminar encuestas con confirmación
|
// - Permite eliminar encuestas con confirmación
|
||||||
// - Muestra el estado (publicada/borrador), fechas y conteo de respuestas
|
// - Muestra el estado (publicada/borrador), fechas y conteo de respuestas
|
||||||
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@repo/shadcn/button';
|
import { Button } from '@repo/shadcn/button';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@repo/shadcn/card';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useSurveysForUserQuery } from '@/feactures/surveys/hooks/use-query-surveys';
|
import { useAllSurveysInfiniteQuery } from '@/feactures/surveys/hooks/use-query-surveys';
|
||||||
import { SurveyAnswerForUser } from '../schemas/survey';
|
import { SurveyCard } from '@/feactures/surveys/components/survey-card';
|
||||||
import { Badge } from '@repo/shadcn/badge';
|
|
||||||
import { BadgeCheck } from 'lucide-react';
|
import { SurveyAnswerForUser } from '../schemas/survey';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Input } from '@repo/shadcn/components/ui/input';
|
||||||
|
|
||||||
export function SurveyList() {
|
export function SurveyList() {
|
||||||
|
|
||||||
const router = useRouter();
|
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) => {
|
const handleRespond = (surveyId: number) => {
|
||||||
router.push(`/dashboard/encuestas/${surveyId}/responder`);
|
router.push(`/dashboard/encuestas/${surveyId}/responder`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// console.log(surveys?.data)
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{surveys?.meta.totalPages === 0 ? (
|
<form onSubmit={formSubmit} action={''} className='col-span-full text-center py-3 flex gap-3'>
|
||||||
<div className="col-span-full text-center py-10">
|
<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>
|
<p className="text-muted-foreground">No hay encuestas disponibles en este momento.</p>
|
||||||
</div>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
surveys?.data.map((data: SurveyAnswerForUser) => (
|
<>
|
||||||
|
{surveys.map((data: SurveyAnswerForUser, index) => {
|
||||||
<Card key={data.surveys.id} className="flex flex-col">
|
const isLastElement = index === surveys.length - 1;
|
||||||
<CardHeader>
|
return (
|
||||||
<CardTitle>{data.surveys.title}</CardTitle>
|
<div ref={isLastElement ? lastProductRef : null} key={data.surveys.id}>
|
||||||
<CardDescription>{data.surveys.description}</CardDescription>
|
<SurveyCard data={data} onClick={handleRespond}/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-grow">
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Fecha de creación:</span>
|
|
||||||
{/* <span>{data.surveys.created_at.toLocaleDateString()}</span> */}
|
|
||||||
<span>{new Date(data.surveys.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
{data.surveys.closingDate && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Fecha de cierre:</span>
|
|
||||||
{/* <span>{data.surveys.closingDate.toLocaleDateString()}</span> */}
|
|
||||||
<span>{new Date(data.surveys.closingDate).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)
|
||||||
<CardFooter className="flex justify-center">
|
})}
|
||||||
{data.answers_surveys === null ? (
|
{isFetchingNextPage && (
|
||||||
<Button
|
<section className="col-span-full text-center py-10">
|
||||||
className="w-full"
|
<p className="text-muted-foreground">Cargando más productos...</p>
|
||||||
onClick={() => handleRespond(Number(data.surveys.id))}
|
</section>
|
||||||
>
|
)}
|
||||||
Responder
|
</>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Badge className="px-4 py-2 w-full bg-green-600 text-black">
|
|
||||||
<BadgeCheck size={28} />
|
|
||||||
Realizada
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'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";
|
import { getSurveyByIdAction, getSurveysAction, getSurveysForUserAction } from "../actions/surveys-actions";
|
||||||
|
|
||||||
|
|
||||||
@@ -8,13 +8,25 @@ export function useSurveysQuery(params = {}) {
|
|||||||
return useSafeQuery(['surveys',params], () => getSurveysAction(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 = {}) {
|
export function useSurveysForUserQuery(params = {}) {
|
||||||
return useSafeQuery(['surveys',params], () => getSurveysForUserAction(params))
|
return useSafeQuery(['surveys',params], () => getSurveysForUserAction(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function useSurveysByIdQuery(id: number) {
|
export function useSurveysByIdQuery(id: number) {
|
||||||
return useSafeQuery(['surveys',id], () => getSurveyByIdAction(id))
|
return useSafeQuery(['surveys',id], () => getSurveyByIdAction(id))
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export function CreateUserForm({
|
|||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
id: defaultValues?.id,
|
id: defaultValues?.id,
|
||||||
phone: defaultValues?.phone || '',
|
phone: defaultValues?.phone || '',
|
||||||
role: defaultValues?.role,
|
role: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = useForm<CreateUser>({
|
const form = useForm<CreateUser>({
|
||||||
@@ -68,9 +68,8 @@ export function CreateUserForm({
|
|||||||
mode: 'onChange', // Enable real-time validation
|
mode: 'onChange', // Enable real-time validation
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: CreateUser) => {
|
const onSubmit = async (formData: CreateUser) => {
|
||||||
|
console.log(formData);
|
||||||
const formData = data
|
|
||||||
|
|
||||||
saveAccountingAccounts(formData, {
|
saveAccountingAccounts(formData, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -185,10 +184,7 @@ export function CreateUserForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormLabel>Rol</FormLabel>
|
<FormLabel>Rol</FormLabel>
|
||||||
<Select
|
<Select onValueChange={(value) => field.onChange(Number(value))}>
|
||||||
onValueChange={(value) => field.onChange(Number(value))}
|
|
||||||
defaultValue={String(field.value)}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecciona un rol" />
|
<SelectValue placeholder="Selecciona un rol" />
|
||||||
|
|||||||
@@ -171,9 +171,7 @@ export function UpdateUserForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormLabel>Rol</FormLabel>
|
<FormLabel>Rol</FormLabel>
|
||||||
<Select onValueChange={(value) => field.onChange(Number(value))}
|
<Select onValueChange={(value) => field.onChange(Number(value))}>
|
||||||
// defaultValue={String(field.value)}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecciona un rol" />
|
<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>(
|
export function useSafeQuery<T, K = unknown>(
|
||||||
queryKey: [string, K?],
|
queryKey: [string, K?],
|
||||||
@@ -11,3 +12,16 @@ export function useSafeQuery<T, K = unknown>(
|
|||||||
...options,
|
...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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ const authConfig: NextAuthConfig = {
|
|||||||
password: credentials?.password as string,
|
password: credentials?.password as string,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Asigna el tipo `SignInActionResult` que ahora incluye `null`
|
// Asigna el tipo `SignInActionResult` que ahora incluye `null`
|
||||||
const response: SignInActionResult = await SignInAction(credential);
|
const response: SignInActionResult = await SignInAction(credential);
|
||||||
|
|
||||||
// **NUEVO: Manejar el caso `null` primero**
|
// **NUEVO: Manejar el caso `null` primero**
|
||||||
@@ -69,15 +69,15 @@ const authConfig: NextAuthConfig = {
|
|||||||
response.type === 'UNKNOWN_ERROR') // Incluye todos los tipos de error posibles
|
response.type === 'UNKNOWN_ERROR') // Incluye todos los tipos de error posibles
|
||||||
) {
|
) {
|
||||||
// Si es un error, lánzalo. Este camino termina aquí.
|
// 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)) {
|
if (!('user' in response)) {
|
||||||
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null,
|
// Esto solo ocurriría si SignInAction devolvió un objeto que no es null,
|
||||||
// no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
|
// no es un error conocido por 'type', PERO tampoco tiene la propiedad 'user'.
|
||||||
// Es un caso de respuesta inesperada del API.
|
// Es un caso de respuesta inesperada del API.
|
||||||
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.");
|
console.error("Respuesta de SignInAction con formato inesperado: falta la propiedad 'user'.");
|
||||||
throw new CredentialsSignin("Error en el formato de la respuesta del servidor.");
|
throw new CredentialsSignin("Error en el formato de la respuesta del servidor.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -100,50 +100,77 @@ const authConfig: NextAuthConfig = {
|
|||||||
signIn: '/', //sigin page
|
signIn: '/', //sigin page
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({
|
async jwt({ token, user }:{
|
||||||
token,
|
user: User
|
||||||
user
|
token: any
|
||||||
}: {
|
|
||||||
token: any;
|
|
||||||
user: User;
|
|
||||||
}) {
|
}) {
|
||||||
// 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) {
|
if (user) {
|
||||||
token.id = user.id;
|
return {
|
||||||
token.username = user.username;
|
id: user.id,
|
||||||
token.fullname = user.fullname;
|
username: user.username,
|
||||||
token.email = user.email;
|
fullname: user.fullname,
|
||||||
token.role = user.role;
|
email: user.email,
|
||||||
token.access_token = user.access_token;
|
role: user.role,
|
||||||
token.access_expire_in = user.access_expire_in;
|
access_token: user.access_token,
|
||||||
token.refresh_token = user.refresh_token;
|
access_expire_in: user.access_expire_in,
|
||||||
token.refresh_expire_in = user.refresh_expire_in;
|
refresh_token: user.refresh_token,
|
||||||
|
refresh_expire_in: user.refresh_expire_in
|
||||||
|
}
|
||||||
|
// return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renovar access_token si ha expirado
|
// 2. Si no es un nuevo login, verificar la expiración del token
|
||||||
if (Date.now() / 1000 > (token.access_expire_in as number)) {
|
const now = Math.floor(Date.now() / 1000); // Usar Math.floor para un número entero
|
||||||
if (Date.now() / 1000 > (token.refresh_expire_in as number)) {
|
|
||||||
return null; // Forzar logout
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Verificar si el token de acceso aún es válido
|
||||||
const res = await resfreshTokenAction({
|
if (now < (token.access_expire_in as number)) {
|
||||||
token: token.refresh_token as string,
|
return token; // Si no ha expirado, no hacer nada y devolver el token actual
|
||||||
});
|
|
||||||
if (!res) throw new Error('Failed to refresh token');
|
|
||||||
token.access_token = res.tokens.access_token;
|
|
||||||
token.access_expire_in = res.tokens.access_expire_in;
|
|
||||||
token.refresh_token = res.tokens.refresh_token;
|
|
||||||
token.refresh_expire_in = res.tokens.refresh_expire_in;
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
// console.log("Now Access Expire:",token.access_expire_in);
|
||||||
|
|
||||||
|
|
||||||
|
// 3. Si el token de acceso ha expirado, verificar el refresh token
|
||||||
|
// console.log("Access token ha expirado. Verificando refresh token...");
|
||||||
|
if (now > (token.refresh_expire_in as number)) {
|
||||||
|
// console.log("Refresh token ha expirado. Forzando logout.");
|
||||||
|
return null; // Forzar el logout al devolver null
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("token:", token.refresh_token);
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Si el token de acceso ha expirado pero el refresh token es válido, renovar
|
||||||
|
// console.log("Renovando token de acceso...");
|
||||||
|
try {
|
||||||
|
const res = await resfreshTokenAction({ token: token.refresh_token as string });
|
||||||
|
|
||||||
|
if (!res || !res.tokens) {
|
||||||
|
throw new Error('Fallo en la respuesta de la API de refresco.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("Old Access Expire:", token.access_expire_in);
|
||||||
|
// console.log("New Access Expire:", res.tokens.access_expire_in);
|
||||||
|
|
||||||
|
// console.log("token:", token.refresh_token);
|
||||||
|
|
||||||
|
|
||||||
|
// Actualizar el token directamente con los nuevos valores
|
||||||
|
token.access_token = res.tokens.access_token;
|
||||||
|
token.access_expire_in = res.tokens.access_expire_in;
|
||||||
|
token.refresh_token = res.tokens.refresh_token;
|
||||||
|
token.refresh_expire_in = res.tokens.refresh_expire_in;
|
||||||
|
return token;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al renovar el token: ", error);
|
||||||
|
return null; // Fallo al renovar, forzar logout
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async session({ session, token }: { session: Session; token: DefaultJWT }) {
|
async session({ session, token }: { session: Session; token: any }) {
|
||||||
session.access_token = token.access_token as string;
|
session.access_token = token.access_token as string;
|
||||||
session.access_expire_in = token.access_expire_in as number;
|
session.access_expire_in = token.access_expire_in as number;
|
||||||
session.refresh_token = token.refresh_token as string;
|
session.refresh_token = token.refresh_token as string;
|
||||||
|
|||||||
@@ -1,46 +1,49 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { env } from '@/lib/env'; // Importamos la configuración de entorno validada
|
import { env } from '@/lib/env';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Crear instancia de Axios con la URL base validada
|
// Crear instancia de Axios con la URL base validada
|
||||||
const fetchApi = axios.create({
|
const fetchApi = axios.create({
|
||||||
baseURL: env.API_URL, // Aquí usamos env.API_URL en vez de process.env.BACKEND_URL
|
baseURL: env.API_URL,
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Interceptor para incluir el token automáticamente en las peticiones
|
// 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) => {
|
fetchApi.interceptors.request.use(async (config: any) => {
|
||||||
try {
|
try {
|
||||||
// Importación dinámica para evitar la referencia circular
|
// console.log("Solicitando autenticación...");
|
||||||
const { auth } = await import('@/lib/auth');
|
|
||||||
|
const { auth } = await import('@/lib/auth'); // Importación dinámica
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const token = session?.access_token;
|
const token = session?.access_token;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting auth token:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
// **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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// safeFetchApi sigue siendo útil para el resto de las llamadas que requieren autenticación
|
||||||
* 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>>(
|
export const safeFetchApi = async <T extends z.ZodSchema<any>>(
|
||||||
schema: T,
|
schema: T,
|
||||||
url: string,
|
url: string,
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
|
||||||
body?: any,
|
data?: any,
|
||||||
): Promise<
|
): Promise<
|
||||||
[{ type: string; message: string; details?: any } | null, z.infer<T> | null]
|
[{ 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({
|
const response = await fetchApi({
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
data: body,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = schema.safeParse(response.data);
|
const parsed = schema.safeParse(response.data);
|
||||||
@@ -60,7 +63,6 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
|
|||||||
expectedSchema: schema,
|
expectedSchema: schema,
|
||||||
data: response.data.data,
|
data: response.data.data,
|
||||||
});
|
});
|
||||||
// console.error(parsed.error.errors)
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'VALIDATION_ERROR',
|
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '5mb',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function DialogContent({
|
|||||||
onInteractOutside={(e) => e.preventDefault()}
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col space-y-4">
|
<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">
|
<div className="absolute bottom-0 left-0 right-0 top-0 flex rounded-md border">
|
||||||
<Table className="relative">
|
<Table className="relative">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user