Merge branch 'inventory'

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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