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

@@ -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} />
);
}