Guardar y cambiar img al editar productos

This commit is contained in:
2025-08-12 15:58:32 -04:00
parent 8a54bf7138
commit 854b31f594
20 changed files with 162 additions and 50 deletions

View File

@@ -17,7 +17,7 @@ export async function seedProducts(db: NodePgDatabase<typeof schema>) {
status:'PUBLICADO', // PUBLICADO, AGOTADO, BORRADOR status:'PUBLICADO', // PUBLICADO, AGOTADO, BORRADOR
urlImg:'apple.avif', urlImg:'apple.avif',
userId:1, userId:1,
gallery: ["Pruebas"] gallery: ["Pruebas.png","Pruebas2.png"]
} }
]; ];

View File

@@ -54,13 +54,16 @@ export class AuthController {
@Patch('refresh-token') @Patch('refresh-token')
//@RequirePermissions('auth:refresh-token') //@RequirePermissions('auth:refresh-token')
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
console.log("Refrescando token...");
console.log(refreshTokenDto);
return await this.authService.refreshToken(refreshTokenDto); return await this.authService.refreshToken(refreshTokenDto);
} }
@Public() // @Public()
@HttpCode(200) // @HttpCode(200)
@Get('test') // @Get('test')
async test() { // async test() {
return 'aplication test success'; // return 'aplication test success';
} // }
} }

View File

@@ -5,6 +5,12 @@ import { CreateProductDto } from './create-product.dto';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
export class UpdateProductDto extends PartialType(CreateProductDto) { export class UpdateProductDto extends PartialType(CreateProductDto) {
@ApiProperty()
@IsString({
message: 'id must be a number',
})
id: string;
@IsOptional() @IsOptional()
title: string; title: string;
@@ -25,4 +31,10 @@ export class UpdateProductDto extends PartialType(CreateProductDto) {
@IsOptional() @IsOptional()
urlImg: string; urlImg: string;
// @ApiProperty()
// @IsString({
// message: 'userId must be a number',
// })
// userId: number;
} }

View File

@@ -16,6 +16,9 @@ export class Inventory {
price: string | null; price: string | null;
stock: number | null; stock: number | null;
urlImg: string | null; urlImg: string | null;
gallery: string[] | null;
address: string | null;
status: string | null;
} }
export class Store { export class Store {

View File

@@ -1,4 +1,5 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req } from '@nestjs/common'; 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 { InventoryService } from './inventory.service';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto'; import { UpdateProductDto } from './dto/update-product.dto';
@@ -62,7 +63,7 @@ export class UsersController {
return { message: 'User created successfully', data }; return { message: 'User created successfully', data };
} }
@Patch(':id') @Patch('/id/:id')
// @Roles('admin') // @Roles('admin')
@ApiOperation({ summary: 'Update a product' }) @ApiOperation({ summary: 'Update a product' })
@ApiResponse({ status: 200, description: 'Product updated successfully.' }) @ApiResponse({ status: 200, description: 'Product updated successfully.' })
@@ -72,6 +73,27 @@ export class UsersController {
return { message: 'User updated successfully', data }; return { message: 'User updated 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) {
// Aquí puedes acceder a los campos del formulario
// console.log('Archivos:', files);
const id = Number(req['user'].id);
// console.log(req['user'].id)
// console.log('Otros campos del formulario:', body);
const result = await this.inventoryService.saveImages(files,body,id);
// const result = ['result']
return { data: result };
}
// @Delete(':id') // @Delete(':id')
// @Roles('admin') // @Roles('admin')
// @ApiOperation({ summary: 'Delete a user' }) // @ApiOperation({ summary: 'Delete a user' })

View File

@@ -8,6 +8,9 @@ import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto'; import { UpdateProductDto } from './dto/update-product.dto';
import { Product, Store, Inventory } from './entities/inventory.entity'; import { Product, Store, Inventory } from './entities/inventory.entity';
import { PaginationDto } from '../../common/dto/pagination.dto'; 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() @Injectable()
export class InventoryService { export class InventoryService {
@@ -57,7 +60,9 @@ export class InventoryService {
price: products.price, price: products.price,
stock: products.stock, stock: products.stock,
status: products.status, status: products.status,
urlImg: products.urlImg urlImg: products.urlImg,
gallery: products.gallery,
userId: products.userId
}) })
.from(products) .from(products)
.where(searchCondition) .where(searchCondition)
@@ -208,7 +213,7 @@ export class InventoryService {
async update(id: string, updateProductDto: UpdateProductDto): Promise<Product> { async update(id: string, updateProductDto: UpdateProductDto): Promise<Product> {
const productId = parseInt(id); const productId = parseInt(id);
console.log(updateProductDto); // console.log(updateProductDto);
// Check if exists // Check if exists
await this.findOne(id); await this.findOne(id);
@@ -229,6 +234,54 @@ export class InventoryService {
// return this.findOne(id); // return this.findOne(id);
} }
/**
* 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[], updateProductDto: UpdateProductDto, id: number): Promise<Product> {
const productId = parseInt(id.toString());
// Construye la ruta al directorio de imágenes.
const picturesPath = join(__dirname, '..', '..', '..', '..', 'web', 'public', 'uploads', 'inventory', id.toString());
// --- NUEVA LÓGICA: Borrar el directorio anterior ---
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);
}
// --- FIN DE LA NUEVA LÓGICA ---
// Crea el directorio si no existe (ya que lo acabamos de borrar o no existía).
await mkdir(picturesPath, { recursive: true });
let gallery: string[] = [];
// 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;
const [updatedProduct] = await this.drizzle.update(products).set(updateData).where(eq(products.id, productId)).returning();
return updatedProduct;
}
// async remove(id: string): Promise<{ message: string, data: User }> { // async remove(id: string): Promise<{ message: string, data: User }> {
// const userId = parseInt(id); // const userId = parseInt(id);

View File

@@ -12,9 +12,7 @@ export class PicturesController {
// Aquí puedes acceder a los campos del formulario // Aquí puedes acceder a los campos del formulario
// console.log('Archivos:', files); // console.log('Archivos:', files);
// console.log('Otros campos del formulario:', body); // console.log('Otros campos del formulario:', body);
const result = await this.picturesService.saveImages(files); const result = await this.picturesService.saveImages(files);
console.log(result);
return { data: result }; return { data: result };
} }

View File

@@ -11,6 +11,7 @@ export class PicturesService {
* @returns La ruta de la imagen guardada. * @returns La ruta de la imagen guardada.
*/ */
async saveImages(file: Express.Multer.File[]): Promise<string[]> { async saveImages(file: Express.Multer.File[]): Promise<string[]> {
// Construye la ruta al directorio de imágenes.
const picturesPath = join(__dirname, '..', '..', '..', '..', 'uploads','pict'); const picturesPath = join(__dirname, '..', '..', '..', '..', 'uploads','pict');
@@ -22,27 +23,19 @@ export class PicturesService {
file.forEach(async (file) => { file.forEach(async (file) => {
count++ count++
// Crea un nombre de archivo único para la imagen.
const fileName = `${Date.now()}-${count}-${file.originalname}`; const fileName = `${Date.now()}-${count}-${file.originalname}`;
images.push(fileName); images.push(fileName);
// console.log(fileName); // console.log(fileName);
// Construye la ruta completa al archivo de imagen.
const filePath = join(picturesPath, fileName); const filePath = join(picturesPath, fileName);
// Escribe el archivo de imagen en el disco.
await writeFile(filePath, file.buffer); await writeFile(filePath, file.buffer);
}); });
// Devuelve la ruta de la imagen guardada.
// return [file[0].originalname] // return [file[0].originalname]
return images; return images;
// // Construye la ruta al directorio de imágenes.
// const picturesPath = join(__dirname, '..', '..', 'pictures');
// // Crea un nombre de archivo único para la imagen.
// const fileName = `${Date.now()}-${file.originalname}`;
// // 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 `/pictures/${fileName}`;
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

View File

@@ -6,6 +6,7 @@ import {
} from '../schemas/refreshToken'; } from '../schemas/refreshToken';
export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => { export const resfreshTokenAction = async (refreshToken: RefreshTokenValue) => {
// return null // Descomentar esto evita que se tenga que borrar cache al navegador
const [error, data] = await safeFetchApi( const [error, data] = await safeFetchApi(
RefreshTokenResponseSchema, RefreshTokenResponseSchema,
'/auth/refreshToken', '/auth/refreshToken',

View File

@@ -64,7 +64,7 @@ export const getAllProducts = async (params: {
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
}) => { }) => {
const session = await auth() // const session = await auth()
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
page: (params.page || 1).toString(), page: (params.page || 1).toString(),
@@ -74,7 +74,7 @@ export const getAllProducts = async (params: {
...(params.sortOrder && { sortOrder: params.sortOrder }), ...(params.sortOrder && { sortOrder: params.sortOrder }),
}) })
const id = session?.user.id // const id = session?.user.id
const [error, response] = await safeFetchApi( const [error, response] = await safeFetchApi(
productApiResponseSchema, productApiResponseSchema,
@@ -152,8 +152,8 @@ export const updateUserAction2 = async (payload: InventoryTable) => {
const [error, data] = await safeFetchApi( const [error, data] = await safeFetchApi(
test, test,
`/pictures/upload`, `/products/upload`,
'POST', 'PATCH',
payload, payload,
); );
@@ -161,7 +161,7 @@ export const updateUserAction2 = async (payload: InventoryTable) => {
console.error(error); console.error(error);
throw new Error(error?.message || 'Error al actualizar el producto'); throw new Error(error?.message || 'Error al actualizar el producto');
} }
// console.log(data); console.log(data);
return data; return data;
} catch (error) { } catch (error) {

View File

@@ -27,7 +27,7 @@ export default function UsersAdminList({
const {data, isLoading} = useProductQuery(filters) const {data, isLoading} = useProductQuery(filters)
// console.log(data?.data); console.log(data?.data);
if (isLoading) { if (isLoading) {
return <DataTableSkeleton columnCount={6} rowCount={initialLimit} />; return <DataTableSkeleton columnCount={6} rowCount={initialLimit} />;

View File

@@ -5,13 +5,16 @@ import { CellAction } from './cell-action';
import { InventoryTable } from '../../../schemas/inventory'; import { InventoryTable } from '../../../schemas/inventory';
export const columns: ColumnDef<InventoryTable>[] = [ export const columns: ColumnDef<InventoryTable>[] = [
{
accessorKey: 'userId',
header: 'ID',
},
{ {
accessorKey: 'urlImg', accessorKey: 'urlImg',
header: 'img', header: 'img',
cell: ({ row }) => { cell: ({ row }) => {
const url = row.getValue("urlImg") as string | undefined;
return ( return (
<img src={`http://localhost:3000/${url}`} alt="" width={64} height={64} className="rounded"/> <img src={`http://localhost:3000/uploads/inventory/${row.original.userId}/${row.original.urlImg}`} alt="" width={64} height={64} className="rounded"/>
) )
}, },
}, },

View File

@@ -22,7 +22,7 @@ import { useUpdateUser } from "@/feactures/inventory/hooks/use-mutation";
import { editInventory, EditInventory } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad import { editInventory, EditInventory } from '@/feactures/inventory/schemas/inventory'; // Renombrado EditInventory para claridad
import { Textarea } from '@repo/shadcn/components/ui/textarea'; import { Textarea } from '@repo/shadcn/components/ui/textarea';
import {STATUS} from '@/constants/status' import {STATUS} from '@/constants/status'
import { useState } from 'react'; import { useState, useEffect } from 'react';
import {sizeFormate} from "@/feactures/inventory/utils/sizeFormate" import {sizeFormate} from "@/feactures/inventory/utils/sizeFormate"
import { z } from 'zod'; // Asegúrate de importar Zod import { z } from 'zod'; // Asegúrate de importar Zod
@@ -58,6 +58,13 @@ export function UpdateForm({
} = useUpdateUser(); } = useUpdateUser();
const [sizeFile, setSizeFile] = useState('0 bytes'); 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í const defaultformValues: EditInventory = { // Usamos el nuevo tipo aquí
id: defaultValues?.id, id: defaultValues?.id,
@@ -226,7 +233,7 @@ export function UpdateForm({
render={({ field: { onChange, onBlur, name, ref } }) => ( render={({ field: { onChange, onBlur, name, ref } }) => (
<FormItem> <FormItem>
<FormLabel>Imagen</FormLabel> <FormLabel>Imagen</FormLabel>
<p>Peso máximo: 5MB / {sizeFile}</p> <p>Peso máximo: 5MB / {sizeFile} <span className='text-xs text-destructive'>(Máximo 10 archivos)</span></p>
<FormControl> <FormControl>
<Input <Input
type="file" type="file"
@@ -238,15 +245,31 @@ export function UpdateForm({
if (e.target.files) { if (e.target.files) {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
let size = 0; let size = 0;
files.forEach(element => size += element.size) const newPreviewUrls: string[] = [];
const tamañoFormateado = sizeFormate(size)
files.forEach(element => {
size += element.size;
newPreviewUrls.push(URL.createObjectURL(element));
});
const tamañoFormateado = sizeFormate(size);
setSizeFile(tamañoFormateado); setSizeFile(tamañoFormateado);
onChange(e.target.files); // Esto ahora pasará FileList a react-hook-form setPreviewUrls(newPreviewUrls);
onChange(e.target.files);
} else {
setPreviewUrls([]);
} }
}} }}
/> />
</FormControl> </FormControl>
<FormMessage /> <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> </FormItem>
)} )}
/> />

View File

@@ -20,6 +20,7 @@ export const product = z.object({
stock: z.number(), stock: z.number(),
price: z.string(), price: z.string(),
urlImg: z.custom<FileList | undefined>().optional(), urlImg: z.custom<FileList | undefined>().optional(),
gallery: z.array(z.string()).optional(),
// urlImg: z.string(), // urlImg: z.string(),
status: z.string(), status: z.string(),
userId: z.number().optional() userId: z.number().optional()

View File

@@ -69,7 +69,7 @@ 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)) {
@@ -136,7 +136,7 @@ const authConfig: NextAuthConfig = {
token.refresh_token = res.tokens.refresh_token; token.refresh_token = res.tokens.refresh_token;
token.refresh_expire_in = res.tokens.refresh_expire_in; token.refresh_expire_in = res.tokens.refresh_expire_in;
} catch (error) { } catch (error) {
console.log(error); console.log("error: ",error);
return null; return null;
} }
} }

View File

@@ -54,7 +54,7 @@ export const safeFetchApi = async <T extends z.ZodSchema<any>>(
[{ type: string; message: string; details?: any } | null, z.infer<T> | null] [{ type: string; message: string; details?: any } | null, z.infer<T> | null]
> => { > => {
try { try {
// console.log(url,method,data); console.log(url,method,data);
const response = await fetchApi({ const response = await fetchApi({
method, method,

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

View File

@@ -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}