base con autenticacion, registro, modulo encuestas
This commit is contained in:
17
apps/web/feactures/auth/actions/login-action.ts
Normal file
17
apps/web/feactures/auth/actions/login-action.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib';
|
||||
import { loginResponseSchema, UserFormValue } from '../schemas/login';
|
||||
|
||||
export const SignInAction = async (payload: UserFormValue) => {
|
||||
const [error, data] = await safeFetchApi(
|
||||
loginResponseSchema,
|
||||
'/auth/sign-in',
|
||||
'POST',
|
||||
payload,
|
||||
);
|
||||
if (error) {
|
||||
return error;
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
};
|
||||
20
apps/web/feactures/auth/actions/refresh-token-action.ts
Normal file
20
apps/web/feactures/auth/actions/refresh-token-action.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib';
|
||||
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;
|
||||
}
|
||||
};
|
||||
27
apps/web/feactures/auth/actions/register.ts
Normal file
27
apps/web/feactures/auth/actions/register.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import { createUserValue, UsersMutate } from '../schemas/register';
|
||||
|
||||
export const registerUserAction = async (payload: createUserValue) => {
|
||||
const { confirmPassword, ...payloadWithoutId } = payload;
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
'/auth/sing-up',
|
||||
'POST',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
// console.error(error);
|
||||
if (error.message === 'Username already exists') {
|
||||
throw new Error('Ese usuario ya existe');
|
||||
}
|
||||
if (error.message === 'Email already exists') {
|
||||
throw new Error('Ese correo ya está en uso');
|
||||
}
|
||||
throw new Error('Error al crear el usuario');
|
||||
}
|
||||
|
||||
return payloadWithoutId;
|
||||
};
|
||||
35
apps/web/feactures/auth/components/sigin-view.tsx
Normal file
35
apps/web/feactures/auth/components/sigin-view.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@repo/shadcn/card';
|
||||
|
||||
import { cn } from '@repo/shadcn/lib/utils';
|
||||
import UserAuthForm from './user-auth-form';
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<UserAuthForm />
|
||||
<div className="relative hidden bg-muted md:block">
|
||||
<img
|
||||
src="logo.png"
|
||||
alt="Image"
|
||||
className="absolute inset-0 p-10 h-full w-full object-cover "
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
|
||||
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
|
||||
and <a href="#">Privacy Policy</a>.
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
32
apps/web/feactures/auth/components/signup-view.tsx
Normal file
32
apps/web/feactures/auth/components/signup-view.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@repo/shadcn/card';
|
||||
|
||||
import { cn } from '@repo/shadcn/lib/utils';
|
||||
// import UserAuthForm from './user-auth-form';
|
||||
import UserAuthForm from './user-register-form';
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'>) {
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="grid p-0">
|
||||
<UserAuthForm />
|
||||
{/* <div className="relative hidden bg-muted md:block">
|
||||
<img
|
||||
src="logo.png"
|
||||
alt="Image"
|
||||
className="absolute inset-0 p-10 h-full w-full object-cover "
|
||||
/>
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
139
apps/web/feactures/auth/components/user-auth-form.tsx
Normal file
139
apps/web/feactures/auth/components/user-auth-form.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'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 { Input } from '@repo/shadcn/input';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { UserFormValue, formSchema } from '../schemas/login';
|
||||
|
||||
export default function UserAuthForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get('callbackUrl');
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [error, SetError] = useState<string | null>(null);
|
||||
const defaultValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
const form = useForm<UserFormValue>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UserFormValue) => {
|
||||
SetError(null); // Limpia cualquier error previo al intentar iniciar sesión
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const login = await signIn('credentials', {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
redirect: false, // No queremos una redirección automática aquí
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (login?.error) {
|
||||
const errorMessage =
|
||||
login.error === 'CredentialsSignin'
|
||||
? 'Usuario o contraseña incorrectos'
|
||||
: 'Contacte al Administrador';
|
||||
SetError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
// Si la autenticación es exitosa y `redirect: false`, necesitamos redirigir manualmente
|
||||
if (login?.ok && !login?.error) {
|
||||
toast.success('Ingreso Exitoso!');
|
||||
router.push(callbackUrl ?? '/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error durante el inicio de sesión:', error);
|
||||
toast.error('Hubo un error inesperado');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
|
||||
<form className="p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Sistema Integral Fondemi</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Ingresa tus datos
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usuario</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ingrese su usuario..."
|
||||
disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="*************"
|
||||
disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
{error && (
|
||||
<FormMessage className="text-red-500">{error}</FormMessage>
|
||||
)}{' '}
|
||||
<Button disabled={loading} type="submit" className="w-full">
|
||||
Ingresar
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
No tienes una cuenta?{" "}
|
||||
<a href="/register" className="underline underline-offset-4">
|
||||
Registrate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
321
apps/web/feactures/auth/components/user-register-form.tsx
Normal file
321
apps/web/feactures/auth/components/user-register-form.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'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 { Input } from '@repo/shadcn/input';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { SelectSearchable } from '@repo/shadcn/select-searchable'
|
||||
|
||||
import { createUserValue, createUser } from '../schemas/register';
|
||||
import { useRegisterUser } from "../hooks/use-mutation-users";
|
||||
|
||||
import React from 'react';
|
||||
import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location';
|
||||
|
||||
export default function UserAuthForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get('callbackUrl');
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [error, SetError] = useState<string | null>(null);
|
||||
|
||||
const [state, setState] = React.useState(0);
|
||||
const [municipality, setMunicipality] = React.useState(0);
|
||||
const [parish, setParish] = React.useState(0);
|
||||
|
||||
const [disabledMunicipality, setDisabledMunicipality] = React.useState(true);
|
||||
const [disabledParish, setDisabledParish] = React.useState(true);
|
||||
|
||||
const { data : dataState } = useStateQuery()
|
||||
const { data : dataMunicipality } = useMunicipalityQuery(state)
|
||||
const { data : dataParish } = useParishQuery(municipality)
|
||||
|
||||
const stateOptions = dataState?.data || [{id:0,name:'Sin estados'}]
|
||||
const municipalityOptions = dataMunicipality?.data || [{id:0,stateId:0,name:'Sin Municipios'}]
|
||||
const parishOptions = dataParish?.data || [{id:0,municipalityId:0,name:'Sin Parroquias'}]
|
||||
|
||||
|
||||
const defaultValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
fullname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 5
|
||||
};
|
||||
const form = useForm<createUserValue>({
|
||||
resolver: zodResolver(createUser),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: saveAccountingAccounts,
|
||||
isPending: isSaving,
|
||||
isError,
|
||||
} = useRegisterUser();
|
||||
|
||||
const onSubmit = async (data: createUserValue) => {
|
||||
SetError(null);
|
||||
|
||||
const formData = {role: 5, ...data }
|
||||
|
||||
saveAccountingAccounts(formData, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
toast.success('Registro Exitoso!');
|
||||
router.push(callbackUrl ?? '/');
|
||||
},
|
||||
onError: (e) => {
|
||||
// form.setError('root', {
|
||||
// type: 'manual',
|
||||
// message: 'Error al guardar la cuenta contable',
|
||||
// });
|
||||
SetError(e.message);
|
||||
toast.error(e.message);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
|
||||
<form className="p-6 md:p-8" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="items-center text-center">
|
||||
<h1 className="text-2xl font-bold">Sistema Integral Fondemi</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Ingresa tus datos
|
||||
</p>
|
||||
{ error ? (
|
||||
<p className="text-balance text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
): null }
|
||||
</div>
|
||||
|
||||
<div className='grid md:grid-cols-2 gap-2'>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usuario</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ingrese su usuario..."
|
||||
// disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fullname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre Completo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ingrese su Nombre..."
|
||||
// disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ingrese su teléfono..."
|
||||
// disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Correo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="ingrese su correo..."
|
||||
// disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Estado</FormLabel>
|
||||
|
||||
<SelectSearchable
|
||||
options={
|
||||
stateOptions?.map((item) => ({
|
||||
value: item.id.toString(),
|
||||
label: item.name,
|
||||
})) || []
|
||||
}
|
||||
onValueChange={(value : any) =>
|
||||
{field.onChange(Number(value)); setState(value); setDisabledMunicipality(false); setDisabledParish(true)}
|
||||
}
|
||||
placeholder="Selecciona un estado"
|
||||
defaultValue={field.value?.toString()}
|
||||
// disabled={readOnly}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="municipality"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Municipio</FormLabel>
|
||||
|
||||
<SelectSearchable
|
||||
options={
|
||||
municipalityOptions?.map((item) => ({
|
||||
value: item.id.toString(),
|
||||
label: item.name,
|
||||
})) || []
|
||||
}
|
||||
onValueChange={(value : any) =>
|
||||
{field.onChange(Number(value)); setMunicipality(value); setDisabledParish(false)}
|
||||
}
|
||||
placeholder="Selecciona un Municipio"
|
||||
defaultValue={field.value?.toString()}
|
||||
disabled={disabledMunicipality}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parish"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Parroquia</FormLabel>
|
||||
|
||||
<SelectSearchable
|
||||
options={
|
||||
parishOptions?.map((item) => ({
|
||||
value: item.id.toString(),
|
||||
label: item.name,
|
||||
})) || []
|
||||
}
|
||||
onValueChange={(value : any) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder="Selecciona una Parroquia"
|
||||
defaultValue={field.value?.toString()}
|
||||
disabled={disabledParish}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="*************"
|
||||
// disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Repita la contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="*************"
|
||||
// disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<FormMessage className="text-red-500">{error}</FormMessage>
|
||||
)}{' '}
|
||||
<Button type="submit" className="w-full">
|
||||
Registrarce
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
¿Ya tienes una cuenta?{" "}
|
||||
<a href="/" className="underline underline-offset-4">Inicia Sesión</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
apps/web/feactures/auth/hooks/use-mutation-users.ts
Normal file
14
apps/web/feactures/auth/hooks/use-mutation-users.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createUserValue } from "../schemas/register";
|
||||
import { registerUserAction } from "../actions/register";
|
||||
|
||||
// Create mutation
|
||||
export function useRegisterUser() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: createUserValue) => registerUserAction(data),
|
||||
// onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
// onError: (e) =>
|
||||
})
|
||||
return mutation
|
||||
}
|
||||
46
apps/web/feactures/auth/schemas/login.ts
Normal file
46
apps/web/feactures/auth/schemas/login.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Definir esquema de validación con Zod para el formulario
|
||||
export const formSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(5, { message: 'Usuario debe tener minimo 5 caracteres' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(6, { message: 'La contraseña debe tener al menos 6 caracteres' }),
|
||||
});
|
||||
|
||||
export type UserFormValue = z.infer<typeof formSchema>;
|
||||
|
||||
// Esquema para el rol
|
||||
const rolSchema = z.object({
|
||||
id: z.number(),
|
||||
rol: z.string(),
|
||||
});
|
||||
|
||||
// Esquema para el usuario
|
||||
const userSchema = z.object({
|
||||
id: z.number(),
|
||||
username: z.string(),
|
||||
fullname: z.string(),
|
||||
email: z.string().email(),
|
||||
rol: z.array(rolSchema),
|
||||
});
|
||||
|
||||
// Esquema para los tokens
|
||||
export const tokensSchema = z.object({
|
||||
access_token: z.string(),
|
||||
access_expire_in: z.number(),
|
||||
refresh_token: z.string(),
|
||||
refresh_expire_in: z.number(),
|
||||
});
|
||||
|
||||
// Esquema final para la respuesta del backend
|
||||
export const loginResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
user: userSchema,
|
||||
tokens: tokensSchema,
|
||||
});
|
||||
|
||||
// Tipo TypeScript basado en el esquema de Zod
|
||||
export type LoginResponse = z.infer<typeof loginResponseSchema>;
|
||||
14
apps/web/feactures/auth/schemas/refreshToken.ts
Normal file
14
apps/web/feactures/auth/schemas/refreshToken.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { tokensSchema } from './login';
|
||||
|
||||
// Esquema para el refresh token
|
||||
export const refreshTokenSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type RefreshTokenValue = z.infer<typeof refreshTokenSchema>;
|
||||
|
||||
// Esquema final para la respuesta del backend
|
||||
export const RefreshTokenResponseSchema = z.object({
|
||||
tokens: tokensSchema,
|
||||
});
|
||||
35
apps/web/feactures/auth/schemas/register.ts
Normal file
35
apps/web/feactures/auth/schemas/register.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
// Definir esquema de validación con Zod para el formulario
|
||||
export const createUser = z.object({
|
||||
username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }),
|
||||
password: z.string().min(8, { message: "Debe de tener 8 o más caracteres" }),
|
||||
email: z.string().email({ message: "Correo no válido" }),
|
||||
fullname: z.string(),
|
||||
phone: z.string(),
|
||||
state: z.number(),
|
||||
municipality: z.number(),
|
||||
parish: z.number(),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'La contraseña no coincide',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export type createUserValue = z.infer<typeof createUser>;
|
||||
|
||||
export const user = z.object({
|
||||
id: z.number().optional(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
fullname: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
role: z.string()
|
||||
});
|
||||
|
||||
export const UsersMutate = z.object({
|
||||
message: z.string(),
|
||||
data: user,
|
||||
})
|
||||
|
||||
36
apps/web/feactures/location/actions/actions.ts
Normal file
36
apps/web/feactures/location/actions/actions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import {responseStates, responseMunicipalities, responseParishes} from '../schemas/users';
|
||||
|
||||
// import { auth } from '@/lib/auth';
|
||||
|
||||
|
||||
export const getStateAction = async () => {
|
||||
const [error, response] = await safeFetchApi(
|
||||
responseStates,
|
||||
`/location/state/`,
|
||||
'GET'
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getMunicipalityAction = async (id : number) => {
|
||||
const [error, response] = await safeFetchApi(
|
||||
responseMunicipalities,
|
||||
`/location/municipality/${id}`,
|
||||
'GET'
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getParishAction = async (id : number) => {
|
||||
const [error, response] = await safeFetchApi(
|
||||
responseParishes,
|
||||
`/location/parish/${id}`,
|
||||
'GET'
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
return response;
|
||||
};
|
||||
16
apps/web/feactures/location/hooks/use-query-location.ts
Normal file
16
apps/web/feactures/location/hooks/use-query-location.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
import { useSafeQuery } from "@/hooks/use-safe-query";
|
||||
import { getStateAction, getMunicipalityAction, getParishAction } from "../actions/actions";
|
||||
|
||||
// Hook for users
|
||||
export function useStateQuery() {
|
||||
return useSafeQuery(['state'], () => getStateAction())
|
||||
}
|
||||
|
||||
export function useMunicipalityQuery( stateId : number ) {
|
||||
return useSafeQuery(['municipality', stateId], () => getMunicipalityAction(stateId))
|
||||
}
|
||||
|
||||
export function useParishQuery(municipalityId : number) {
|
||||
return useSafeQuery(['parish', municipalityId], () => getParishAction(municipalityId))
|
||||
}
|
||||
94
apps/web/feactures/location/schemas/users.ts
Normal file
94
apps/web/feactures/location/schemas/users.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type SurveyTable = z.infer<typeof user>;
|
||||
export type CreateUser = z.infer<typeof createUser>;
|
||||
export type UpdateUser = z.infer<typeof updateUser>;
|
||||
|
||||
export const user = z.object({
|
||||
id: z.number().optional(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
fullname: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
role: z.string(),
|
||||
state: z.string().optional().nullable(),
|
||||
municipality: z.string().optional().nullable(),
|
||||
parish: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const createUser = z.object({
|
||||
id: z.number().optional(),
|
||||
username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }),
|
||||
password: z.string().min(8, { message: "Debe de tener 8 o más caracteres" }),
|
||||
email: z.string().email({ message: "Correo no válido" }),
|
||||
fullname: z.string(),
|
||||
phone: z.string(),
|
||||
confirmPassword: z.string(),
|
||||
role: z.number()
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'La contraseña no coincide',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export const updateUser = z.object({
|
||||
id: z.number(),
|
||||
username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }).or(z.literal('')),
|
||||
password: z.string().min(6, { message: "Debe de tener 6 o más caracteres" }).or(z.literal('')),
|
||||
email: z.string().email({ message: "Correo no válido" }).or(z.literal('')),
|
||||
fullname: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
role: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
state: z.number().optional().nullable(),
|
||||
municipality: z.number().optional().nullable(),
|
||||
parish: z.number().optional().nullable(),
|
||||
})
|
||||
|
||||
export const surveysApiResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(user),
|
||||
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 states = z.object({
|
||||
id: z.number(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
export const municipalities = z.object({
|
||||
id: z.number(),
|
||||
stateId: z.number(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
export const parishes = z.object({
|
||||
id: z.number(),
|
||||
municipalityId: z.number(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
export const responseStates = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(states),
|
||||
})
|
||||
|
||||
export const responseMunicipalities = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(municipalities),
|
||||
})
|
||||
|
||||
export const responseParishes = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(parishes),
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import { SurveyStatisticsData } from '../schemas/statistics';
|
||||
import { SurveyStatisticsSchema } from '../schemas/statistics-schema';
|
||||
|
||||
|
||||
export const getSurveysStatistics = async (): Promise<SurveyStatisticsData> => {
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
SurveyStatisticsSchema,
|
||||
`surveys/statistics`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
// console.log(error.details);
|
||||
throw new Error('Ocurrio un error');
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No statistics data available');
|
||||
}
|
||||
|
||||
return data?.data;
|
||||
};
|
||||
|
||||
127
apps/web/feactures/statistics/components/survey-details.tsx
Normal file
127
apps/web/feactures/statistics/components/survey-details.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@repo/shadcn/select';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
|
||||
import { SurveyStatisticsData } from '../schemas/statistics';
|
||||
|
||||
interface SurveyDetailsProps {
|
||||
data: SurveyStatisticsData | undefined;
|
||||
}
|
||||
|
||||
export function SurveyDetails({ data }: SurveyDetailsProps) {
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<string>('');
|
||||
|
||||
if (!data || !data.surveyDetails || data.surveyDetails.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-center text-muted-foreground">No hay datos detallados disponibles</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Set default selected survey if none is selected
|
||||
if (!selectedSurvey && data.surveyDetails.length > 0) {
|
||||
setSelectedSurvey(data.surveyDetails?.[0]?.id.toString() ?? '');
|
||||
}
|
||||
|
||||
const currentSurvey = data.surveyDetails.find(
|
||||
(survey) => survey.id.toString() === selectedSurvey
|
||||
);
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d', '#ffc658'];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detalles por Encuesta</CardTitle>
|
||||
<CardDescription>Análisis detallado de respuestas por encuesta</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select value={selectedSurvey} onValueChange={setSelectedSurvey}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona una encuesta" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.surveyDetails.map((survey) => (
|
||||
<SelectItem key={survey.id} value={survey.id.toString()}>
|
||||
{survey.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{currentSurvey && (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{currentSurvey.title}</CardTitle>
|
||||
<CardDescription>{currentSurvey.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Total de respuestas:</span>
|
||||
<span>{currentSurvey.totalResponses}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Audiencia objetivo:</span>
|
||||
<span>{currentSurvey.targetAudience}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Fecha de creación:</span>
|
||||
<span>{new Date(currentSurvey.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{currentSurvey.closingDate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Fecha de cierre:</span>
|
||||
<span>{new Date(currentSurvey.closingDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{currentSurvey.questionStats && currentSurvey.questionStats.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Distribución de Respuestas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={currentSurvey.questionStats}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={true}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="label"
|
||||
>
|
||||
{currentSurvey.questionStats.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value, name) => [`${value}`, name]} />
|
||||
{/* <Legend /> */}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
apps/web/feactures/statistics/components/survey-overview.tsx
Normal file
82
apps/web/feactures/statistics/components/survey-overview.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { SurveyStatisticsData } from '../schemas/statistics';
|
||||
|
||||
interface SurveyOverviewProps {
|
||||
data: SurveyStatisticsData | undefined;
|
||||
}
|
||||
|
||||
export function SurveyOverview({ data }: SurveyOverviewProps) {
|
||||
if (!data) return null;
|
||||
|
||||
const { totalSurveys, totalResponses, completionRate, surveysByMonth } = data;
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total de Encuestas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalSurveys}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Encuestas creadas en la plataforma
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total de Respuestas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalResponses}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Respuestas recibidas en todas las encuestas
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
{/* <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tasa de Completado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalSurveys/totalResponses}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Porcentaje de encuestas completadas
|
||||
</p>
|
||||
</CardContent> */}
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Encuestas por Mes</CardTitle>
|
||||
<CardDescription>Distribución de encuestas creadas por mes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={surveysByMonth}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip wrapperStyle={{color: '#000', fontWeight: 'bold' }}/>
|
||||
<Legend />
|
||||
<Bar dataKey="count" fill="#8884d8" name="Encuestas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { SurveyStatisticsData } from '../schemas/statistics';
|
||||
|
||||
interface SurveyResponsesProps {
|
||||
data: SurveyStatisticsData | undefined;
|
||||
}
|
||||
|
||||
export function SurveyResponses({ data }: SurveyResponsesProps) {
|
||||
if (!data) return null;
|
||||
|
||||
const { responsesByAudience, responseDistribution } = data;
|
||||
|
||||
const COLORS = ['#0088FE', '#8884d8', '#00C49F', '#FFBB28', '#FF8042'];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Respuestas por Audiencia</CardTitle>
|
||||
<CardDescription>Distribución de respuestas según el tipo de audiencia</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={responsesByAudience}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={true}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{responsesByAudience.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value, name) => [`${value} respuestas`, name]} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Distribución de Respuestas</CardTitle>
|
||||
<CardDescription>Cantidad de respuestas por encuesta</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={responseDistribution}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="title" type="category" width={150} tick={{ fontSize: 12 }} />
|
||||
<Tooltip wrapperStyle={{color: '#000', fontWeight: 'bold' }}/>
|
||||
<Legend />
|
||||
<Bar dataKey="responses" fill="#8884d8" name="Respuestas" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/shadcn/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@repo/shadcn/tabs';
|
||||
import { useSurveysStatsQuery } from '../hooks/use-query-statistics';
|
||||
import { SurveyOverview } from './survey-overview';
|
||||
import { SurveyResponses } from './survey-responses';
|
||||
import { SurveyDetails } from './survey-details';
|
||||
|
||||
export function SurveyStatistics() {
|
||||
const { data, isLoading } = useSurveysStatsQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center p-8">Cargando estadísticas...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="overview">Resumen General</TabsTrigger>
|
||||
<TabsTrigger value="responses">Respuestas</TabsTrigger>
|
||||
<TabsTrigger value="details">Detalles por Encuesta</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<SurveyOverview data={data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="responses">
|
||||
<SurveyResponses data={data} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<SurveyDetails data={data} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useSafeQuery } from '@/hooks/use-safe-query';
|
||||
import { getSurveysStatistics } from '../actions/surveys-statistics-actions';
|
||||
|
||||
|
||||
// Hook for all survesys
|
||||
export function useSurveysStatsQuery() {
|
||||
return useSafeQuery(['surveys-statistics'], () => getSurveysStatistics())
|
||||
}
|
||||
59
apps/web/feactures/statistics/schemas/statistics-schema.ts
Normal file
59
apps/web/feactures/statistics/schemas/statistics-schema.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Esquema para QuestionStat
|
||||
export const QuestionStatSchema = z.object({
|
||||
questionId: z.string(),
|
||||
label: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
// Esquema para SurveyDetail
|
||||
export const SurveyDetailSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
totalResponses: z.number(),
|
||||
targetAudience: z.string(),
|
||||
createdAt: z.string(),
|
||||
closingDate: z.string().optional(),
|
||||
questionStats: z.array(QuestionStatSchema),
|
||||
});
|
||||
|
||||
|
||||
// Esquema para SurveyStatisticsData
|
||||
export const SurveyStatisticsDataSchema = z.object({
|
||||
totalSurveys: z.number(),
|
||||
totalResponses: z.number(),
|
||||
completionRate: z.number(),
|
||||
surveysByMonth: z.array(
|
||||
z.object({
|
||||
month: z.string(),
|
||||
count: z.number(),
|
||||
})
|
||||
),
|
||||
responsesByAudience: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
})
|
||||
),
|
||||
responseDistribution: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
responses: z.number(),
|
||||
})
|
||||
),
|
||||
surveyDetails: z.array(SurveyDetailSchema),
|
||||
// surveyDetails: z.array(z.any()),
|
||||
});
|
||||
|
||||
// Response schemas for the API create, update
|
||||
export const SurveyStatisticsSchema = z.object({
|
||||
message: z.string(),
|
||||
data: SurveyStatisticsDataSchema,
|
||||
});
|
||||
|
||||
// Tipos inferidos de Zod
|
||||
export type SurveyStatisticsType = z.infer<typeof SurveyStatisticsSchema>;
|
||||
export type SurveyDetailType = z.infer<typeof SurveyDetailSchema>;
|
||||
export type QuestionStatType = z.infer<typeof QuestionStatSchema>;
|
||||
35
apps/web/feactures/statistics/schemas/statistics.ts
Normal file
35
apps/web/feactures/statistics/schemas/statistics.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface SurveyStatisticsData {
|
||||
totalSurveys: number;
|
||||
totalResponses: number;
|
||||
completionRate: number;
|
||||
surveysByMonth: {
|
||||
month: string;
|
||||
count: number;
|
||||
}[];
|
||||
responsesByAudience: {
|
||||
name: string;
|
||||
value: number;
|
||||
}[];
|
||||
responseDistribution: {
|
||||
title: string;
|
||||
responses: number;
|
||||
}[];
|
||||
surveyDetails: SurveyDetail[];
|
||||
}
|
||||
|
||||
export interface SurveyDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
totalResponses: number;
|
||||
targetAudience: string;
|
||||
createdAt: string;
|
||||
closingDate?: string;
|
||||
questionStats: QuestionStat[];
|
||||
}
|
||||
|
||||
export interface QuestionStat {
|
||||
questionId: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
216
apps/web/feactures/surveys/actions/surveys-actions.ts
Normal file
216
apps/web/feactures/surveys/actions/surveys-actions.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import { SurveyAnswerMutate, Survey, SurveyResponse, surveysApiResponseSchema, suveryApiMutationResponseSchema, suveryResponseDeleteSchema, surveysApiResponseForUserSchema } from '../schemas/survey';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
|
||||
|
||||
const transformSurvey = (survey: any) => {
|
||||
|
||||
return survey.map((survey: any) => {
|
||||
return {
|
||||
...survey,
|
||||
published: survey.published ? 'Publicada': 'Borrador',
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
export const getSurveysAction = 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(
|
||||
surveysApiResponseSchema,
|
||||
`/surveys?${searchParams}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
|
||||
const transformedData = response?.data ? transformSurvey(response?.data) : undefined;
|
||||
|
||||
return {
|
||||
data: transformedData,
|
||||
meta: response?.meta || {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalCount: 0,
|
||||
totalPages: 1,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
nextPage: null,
|
||||
previousPage: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
export const getSurveysForUserAction = 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 rol = {
|
||||
rol: session?.user.role
|
||||
}
|
||||
const [error, response] = await safeFetchApi(
|
||||
surveysApiResponseForUserSchema,
|
||||
`/surveys/for-user?${searchParams}`,
|
||||
'POST',
|
||||
rol
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
|
||||
const transformedData = response?.data ? transformSurvey(response?.data) : undefined;
|
||||
|
||||
return {
|
||||
data: transformedData,
|
||||
meta: response?.meta || {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalCount: 0,
|
||||
totalPages: 1,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
nextPage: null,
|
||||
previousPage: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createSurveyAction = async (payload: Survey) => {
|
||||
const { id, ...payloadWithoutId } = payload;
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
suveryApiMutationResponseSchema,
|
||||
'/surveys',
|
||||
'POST',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.message === 'Survey already exists') {
|
||||
throw new Error('Ya existe una encuesta con ese titulo');
|
||||
}
|
||||
// console.error('Error:', error);
|
||||
throw new Error('Error al crear la encuesta');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateSurveyAction = async (payload: Survey) => {
|
||||
const { id, ...payloadWithoutId } = payload;
|
||||
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
suveryApiMutationResponseSchema,
|
||||
`/surveys/${id}`,
|
||||
'PATCH',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.message === 'Survey already exists') {
|
||||
throw new Error('Ya existe otra encuesta con ese titulo');
|
||||
}
|
||||
if (error.message === 'Survey not found') {
|
||||
throw new Error('No se encontró la encuesta');
|
||||
}
|
||||
// console.error('Error:', error);
|
||||
// throw new Error(error.message);
|
||||
throw new Error('Error al actualizar la encuesta');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteSurveyAction = async (id: number) => {
|
||||
const [error, data] = await safeFetchApi(
|
||||
suveryResponseDeleteSchema,
|
||||
`/surveys/${id}`,
|
||||
'DELETE',
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSurveyByIdAction = async (id: number) => {
|
||||
const [error, data] = await safeFetchApi(
|
||||
suveryApiMutationResponseSchema,
|
||||
`/surveys/${id}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Error en la API:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const saveSurveysAction = async (payload: Survey) => {
|
||||
try {
|
||||
if (payload.id) {
|
||||
return await updateSurveyAction(payload);
|
||||
} else {
|
||||
return await createSurveyAction(payload);
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message || 'Error saving account surveys');
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSurveyAnswer = async (payload: SurveyResponse) => {
|
||||
const [error, data] = await safeFetchApi(
|
||||
SurveyAnswerMutate,
|
||||
'/surveys/answers',
|
||||
'POST',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (error) {
|
||||
console.error('Error:', error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Modal para configurar cada pregunta individual
|
||||
// Funcionalidades:
|
||||
// - Configuración específica según el tipo de pregunta
|
||||
// - Para títulos: solo contenido
|
||||
// - Para preguntas simples: texto de la pregunta
|
||||
// - Para preguntas con opciones: texto y lista de opciones
|
||||
// - Switch para hacer la pregunta obligatoria/opcional
|
||||
'use client';
|
||||
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@repo/shadcn/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@repo/shadcn/form';
|
||||
import { Input } from '@repo/shadcn/input';
|
||||
import { Switch } from '@repo/shadcn/switch';
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { QuestionType } from '../../schemas/survey';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
interface QuestionConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
question: any;
|
||||
onSave: (config: any) => void;
|
||||
}
|
||||
|
||||
export function QuestionConfigModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
question,
|
||||
onSave,
|
||||
}: QuestionConfigModalProps) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
content: '',
|
||||
question: '',
|
||||
required: false,
|
||||
options: [{ id: '1', text: '' }],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'options',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (question) {
|
||||
form.reset({
|
||||
content: question.content || '',
|
||||
question: question.question || '',
|
||||
required: question.required || false,
|
||||
options: question.options || [{ id: '1', text: '' }],
|
||||
});
|
||||
}
|
||||
}, [question, form]);
|
||||
|
||||
const handleSubmit = (data: any) => {
|
||||
const config = {
|
||||
...question,
|
||||
...data,
|
||||
};
|
||||
|
||||
// Remove options if not needed
|
||||
if (![
|
||||
QuestionType.MULTIPLE_CHOICE,
|
||||
QuestionType.SINGLE_CHOICE,
|
||||
QuestionType.SELECT
|
||||
].includes(question.type)) {
|
||||
delete config.options;
|
||||
}
|
||||
|
||||
// Remove content if not a title
|
||||
if (question.type !== QuestionType.TITLE) {
|
||||
delete config.content;
|
||||
}
|
||||
|
||||
onSave(config);
|
||||
};
|
||||
|
||||
const renderFields = () => {
|
||||
switch (question?.type) {
|
||||
case QuestionType.TITLE:
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contenido del Título</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case QuestionType.SIMPLE:
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="question"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pregunta</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case QuestionType.MULTIPLE_CHOICE:
|
||||
case QuestionType.SINGLE_CHOICE:
|
||||
case QuestionType.SELECT:
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="question"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pregunta</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<FormLabel>Opciones</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ id: `${fields.length + 1}`, text: '' })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Agregar Opción
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[200px] overflow-y-auto pr-2 space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`options.${index}.text`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={`Opción ${index + 1}`} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
aria-describedby="question-config-description"
|
||||
>
|
||||
<div id="question-config-description" className="sr-only">
|
||||
Configuración de la pregunta de la encuesta
|
||||
</div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurar Pregunta</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{renderFields()}
|
||||
|
||||
{question?.type !== QuestionType.TITLE && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="required"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Respuesta Obligatoria</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">Guardar</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Caja de herramientas con tipos de preguntas disponibles
|
||||
// Funcionalidades:
|
||||
// - Lista de elementos arrastrables
|
||||
// - Tipos disponibles: Título, Pregunta Simple, Opción Múltiple, Opción Única, Selección
|
||||
// - Cada elemento es arrastrable al área de construcción
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@repo/shadcn/card';
|
||||
import { QuestionType } from '../../schemas/survey';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
const questionTypes = [
|
||||
{
|
||||
type: QuestionType.TITLE,
|
||||
label: 'Título',
|
||||
icon: '📝',
|
||||
},
|
||||
{
|
||||
type: QuestionType.SIMPLE,
|
||||
label: 'Pregunta Simple',
|
||||
icon: '✏️',
|
||||
},
|
||||
{
|
||||
type: QuestionType.MULTIPLE_CHOICE,
|
||||
label: 'Opción Múltiple',
|
||||
icon: '☑️',
|
||||
},
|
||||
{
|
||||
type: QuestionType.SINGLE_CHOICE,
|
||||
label: 'Opción Única',
|
||||
icon: '⭕',
|
||||
},
|
||||
{
|
||||
type: QuestionType.SELECT,
|
||||
label: 'Selección',
|
||||
icon: '📋',
|
||||
},
|
||||
];
|
||||
|
||||
function DraggableItem({ type, label, icon }: { type: string; label: string; icon: string }) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: type,
|
||||
data: {
|
||||
type,
|
||||
isTemplate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const style = transform ? {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="p-3 bg-background border rounded-lg cursor-move hover:bg-accent touch-none"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionToolbox() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold mb-4">Elementos Disponibles</h3>
|
||||
<div className="space-y-2">
|
||||
{questionTypes.map((item) => (
|
||||
<DraggableItem
|
||||
key={item.type}
|
||||
type={item.type}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
454
apps/web/feactures/surveys/components/admin/survey-builder.tsx
Normal file
454
apps/web/feactures/surveys/components/admin/survey-builder.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
// Componente principal para crear/editar encuestas
|
||||
// Funcionalidades:
|
||||
// - Formulario para datos básicos (título, descripción, fecha de cierre)
|
||||
// - Sistema de drag & drop para agregar preguntas
|
||||
// - Reordenamiento de preguntas existentes
|
||||
// - Guardado como borrador o publicación directa'use client';
|
||||
'use client';
|
||||
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { Card, CardContent } from '@repo/shadcn/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@repo/shadcn/form';
|
||||
import { Input } from '@repo/shadcn/input';
|
||||
import { Textarea } from '@repo/shadcn/textarea';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { QuestionType, Survey } from '../../schemas/survey';
|
||||
import { QuestionConfigModal } from './question-config-modal';
|
||||
import { QuestionToolbox } from './question-toolbox';
|
||||
import { cn } from '@repo/shadcn/lib/utils';
|
||||
import { Calendar } from '@repo/shadcn/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@repo/shadcn/popover';
|
||||
import { format } from 'date-fns';
|
||||
import { DndContext, DragEndEvent, useSensor, useSensors, PointerSensor } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/shadcn/select";
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
// Añade el import de Trash2
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useSurveysByIdQuery } from '../../hooks/use-query-surveys';
|
||||
import { useSurveyMutation } from '../../hooks/use-mutation-surveys';
|
||||
|
||||
|
||||
function SortableQuestion({
|
||||
question,
|
||||
index,
|
||||
onDelete,
|
||||
onEdit
|
||||
}: {
|
||||
question: any;
|
||||
index: number;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (question: any) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: question.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => onEdit(question)}
|
||||
>
|
||||
<span>{question.question || question.content}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(question.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DroppableArea({ children }: { children: React.ReactNode }) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'questions-container',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="min-h-[200px] border-2 border-dashed rounded-lg p-4 mt-6"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SurveyBuilder() {
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [selectedQuestion, setSelectedQuestion] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const surveyId = params?.id as string;
|
||||
const isEditing = Boolean(surveyId);
|
||||
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
closingDate: undefined as Date | undefined,
|
||||
targetAudience: '', // Nuevo campo
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: MutateSurvey,
|
||||
} = useSurveyMutation()
|
||||
|
||||
|
||||
// Remove the loadSurvey function and use the query hook at component level
|
||||
if (isEditing) {
|
||||
const { data: surveyById, isLoading } = useSurveysByIdQuery(parseInt(surveyId))
|
||||
|
||||
// Use useEffect to handle the form reset when data is available
|
||||
useEffect(() => {
|
||||
// console.log(isEditing ? parseInt(surveyId) : 0);
|
||||
if (surveyById?.data && !isLoading) {
|
||||
form.reset({
|
||||
title: surveyById.data.title,
|
||||
description: surveyById.data.description,
|
||||
closingDate: surveyById.data.closingDate || undefined,
|
||||
targetAudience: surveyById.data.targetAudience,
|
||||
});
|
||||
// Fix: Set the questions directly without wrapping in array
|
||||
setQuestions(surveyById.data.questions || []);
|
||||
}
|
||||
}, [surveyById, isLoading, form]);
|
||||
}
|
||||
|
||||
|
||||
// Remove the loadSurvey() call from the component body
|
||||
|
||||
|
||||
|
||||
// Procesa la configuración de una pregunta después de cerrar el modal
|
||||
// Actualiza o agrega la pregunta al listado
|
||||
const handleQuestionConfig = (questionConfig: any) => {
|
||||
if (selectedQuestion) {
|
||||
const updatedQuestions = [...questions];
|
||||
const index = updatedQuestions.findIndex(q => q.id === selectedQuestion.id);
|
||||
|
||||
if (index === -1) {
|
||||
updatedQuestions.push({
|
||||
...selectedQuestion,
|
||||
...questionConfig,
|
||||
});
|
||||
} else {
|
||||
updatedQuestions[index] = {
|
||||
...selectedQuestion,
|
||||
...questionConfig,
|
||||
};
|
||||
}
|
||||
|
||||
setQuestions(updatedQuestions);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
// Maneja el guardado de la encuesta
|
||||
// Valida campos requeridos y guarda como borrador o publicada
|
||||
const handleSave = async (status: 'draft' | 'published') => {
|
||||
const formData = form.getValues();
|
||||
|
||||
// validar que los campos no esten vacíos
|
||||
if (!formData.title) return toast.error('El título es obligatorio')
|
||||
if (!formData.description) return toast.error('La descripción es obligatorio')
|
||||
if (!formData.targetAudience) return toast.error('El público objetivo es obligatorio')
|
||||
if (!formData.closingDate) return toast.error('La fecha de cierre es obligatorio')
|
||||
if (questions.length === 0) return toast.error('Debe agregar al menos una pregunta');
|
||||
|
||||
const surveyData: Omit<Survey, 'created_at'> = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
closingDate: formData.closingDate,
|
||||
targetAudience: formData.targetAudience,
|
||||
published: status === 'published',
|
||||
questions: questions.map((q, index) => ({ ...q, position: index })),
|
||||
};
|
||||
|
||||
try {
|
||||
await MutateSurvey({
|
||||
...surveyData,
|
||||
id: isEditing ? parseInt(surveyId) : undefined,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
isEditing
|
||||
? 'Encuesta actualizada exitosamente'
|
||||
: status === 'published'
|
||||
? 'Encuesta publicada'
|
||||
: 'Encuesta guardada como borrador'
|
||||
);
|
||||
router.push('/dashboard/administracion/encuestas');
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(e.message)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error( `Error al ${isEditing ? 'actualizar' : 'guardar'} la encuesta`)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Configuración de los sensores para el drag and drop
|
||||
// Define la distancia mínima para activar el arrastre
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Manejador del evento cuando se termina de arrastrar un elemento
|
||||
// Gestiona tanto nuevas preguntas como reordenamiento
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
if (active.data.current?.isTemplate) {
|
||||
// Handle new question from toolbox
|
||||
const questionType = active.data.current.type;
|
||||
const newQuestion = {
|
||||
id: `q-${questions.length + 1}`,
|
||||
type: questionType as QuestionType,
|
||||
position: questions.length,
|
||||
required: false,
|
||||
};
|
||||
setSelectedQuestion(newQuestion);
|
||||
setIsModalOpen(true);
|
||||
} else {
|
||||
// Handle reordering of existing questions
|
||||
const oldIndex = questions.findIndex(q => q.id === active.id);
|
||||
const newIndex = questions.findIndex(q => q.id === over.id);
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
const updatedQuestions = [...questions];
|
||||
const [movedQuestion] = updatedQuestions.splice(oldIndex, 1);
|
||||
updatedQuestions.splice(newIndex, 0, movedQuestion);
|
||||
setQuestions(updatedQuestions);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Añade estas funciones manejadoras
|
||||
const handleDeleteQuestion = (id: string) => {
|
||||
setQuestions(questions.filter(q => q.id !== id));
|
||||
};
|
||||
|
||||
const handleEditQuestion = (question: any) => {
|
||||
setSelectedQuestion(question);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-6">
|
||||
<div className="w-64">
|
||||
<QuestionToolbox />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Form {...form}>
|
||||
<form className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Título de la Encuesta</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Descripción</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="closingDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fecha de Cierre</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Seleccione una fecha</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) =>
|
||||
date < new Date()
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetAudience"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Dirigido a</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Seleccione el público objetivo" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="producers">Productores</SelectItem>
|
||||
<SelectItem value="organization">Organización</SelectItem>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DroppableArea>
|
||||
<SortableContext
|
||||
items={questions.map(q => q.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{questions.map((question, index) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
question={question}
|
||||
index={index}
|
||||
onDelete={handleDeleteQuestion}
|
||||
onEdit={handleEditQuestion}
|
||||
/>
|
||||
))}
|
||||
{questions.length === 0 && (
|
||||
<div className="text-center text-muted-foreground p-4">
|
||||
Arrastra elementos aquí para crear la encuesta
|
||||
</div>
|
||||
)}
|
||||
</SortableContext>
|
||||
</DroppableArea>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.push('/dashboard/administracion/encuestas')}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleSave('draft')}>
|
||||
Guardar como Borrador
|
||||
</Button>
|
||||
<Button onClick={() => handleSave('published')}>
|
||||
Publicar
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<QuestionConfigModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
question={selectedQuestion}
|
||||
onSave={handleQuestionConfig}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { DataTable } from '@repo/shadcn/table/data-table';
|
||||
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
|
||||
import { columns } from './surveys-tables/columns';
|
||||
import { useSurveysQuery } from '../../hooks/use-query-surveys';
|
||||
|
||||
interface SurveysAdminListProps {
|
||||
initialPage: number;
|
||||
initialSearch?: string | null;
|
||||
initialLimit: number;
|
||||
initialType?: string | null;
|
||||
}
|
||||
|
||||
export default function SurveysAdminList({
|
||||
initialPage,
|
||||
initialSearch,
|
||||
initialLimit,
|
||||
initialType,
|
||||
}: SurveysAdminListProps) {
|
||||
const filters = {
|
||||
page: initialPage,
|
||||
limit: initialLimit,
|
||||
...(initialSearch && { search: initialSearch }),
|
||||
...(initialType && { type: initialType }),
|
||||
};
|
||||
|
||||
const {data, isLoading} = useSurveysQuery(filters)
|
||||
|
||||
if (isLoading) {
|
||||
return <DataTableSkeleton columnCount={6} rowCount={initialLimit} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data || []}
|
||||
totalItems={data?.meta.totalCount || 0}
|
||||
pageSizeOptions={[10, 20, 30, 40, 50]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { Heading } from '@repo/shadcn/heading';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
|
||||
export function SurveysHeader() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading
|
||||
title="Administración de Encuestas"
|
||||
description="Gestiona las encuestas disponibles en la plataforma"
|
||||
/>
|
||||
<Button onClick={() => router.push(`/dashboard/administracion/encuestas/crear`)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" /> Agregar Encuesta
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'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 } from 'lucide-react';
|
||||
import { SurveyTable } from '@/feactures/surveys/schemas/survey';
|
||||
import { useDeleteSurvey } from '@/feactures/surveys/hooks/use-mutation-surveys';
|
||||
|
||||
|
||||
interface CellActionProps {
|
||||
data: SurveyTable;
|
||||
}
|
||||
|
||||
export const CellAction: React.FC<CellActionProps> = ({ data }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: deleteSurvey } = useDeleteSurvey();
|
||||
const router = useRouter();
|
||||
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
deleteSurvey(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 eliminar la encuesta?"
|
||||
description="Esta acción no se puede deshacer."
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/dashboard/administracion/encuestas/editar/${data.id!}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Editar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Eliminar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Badge } from "@repo/shadcn/badge";
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { CellAction } from './cell-action';
|
||||
import { SurveyTable } from '@/feactures/surveys/schemas/survey';
|
||||
|
||||
export const columns: ColumnDef<SurveyTable>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: 'Título',
|
||||
},
|
||||
{
|
||||
accessorKey: "published",
|
||||
header: "Estado",
|
||||
cell: ({ row }) => {
|
||||
const published = row.getValue("published");
|
||||
return (
|
||||
<Badge variant={published == 'Publicada' ? "default" : "secondary"}>
|
||||
{published == 'Publicada' ? 'Publicada' : 'Borrador'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => <CellAction data={row.original} />,
|
||||
},
|
||||
];
|
||||
@@ -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 SurveysTableAction() {
|
||||
const {
|
||||
typeFilter,
|
||||
searchQuery,
|
||||
setPage,
|
||||
setTypeFilter,
|
||||
setSearchQuery,
|
||||
} = useSurveyTableFilters();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<DataTableSearch
|
||||
searchKey={searchQuery}
|
||||
searchQuery={searchQuery || ''}
|
||||
setSearchQuery={setSearchQuery}
|
||||
setPage={setPage}
|
||||
/>
|
||||
<DataTableFilterBox
|
||||
filterKey="type"
|
||||
title="Estado"
|
||||
options={TYPE_OPTIONS}
|
||||
setFilterValue={setTypeFilter}
|
||||
filterValue={typeFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
};
|
||||
}
|
||||
86
apps/web/feactures/surveys/components/survey-list.tsx
Normal file
86
apps/web/feactures/surveys/components/survey-list.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
// Este componente maneja la lista de encuestas en el panel de administración
|
||||
// Funcionalidades:
|
||||
// - Muestra todas las encuestas en una tabla
|
||||
// - 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 { Survey, SurveyAnswerForUser } from '../schemas/survey';
|
||||
import { Badge } from '@repo/shadcn/badge';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
export function SurveyList() {
|
||||
|
||||
const router = useRouter();
|
||||
const {data: surveys} = useSurveysForUserQuery()
|
||||
|
||||
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">
|
||||
<p className="text-muted-foreground">No hay encuestas disponibles en este momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-center">
|
||||
{data.answers_surveys === null ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleRespond(Number(data.surveys.id))}
|
||||
>
|
||||
Responder
|
||||
</Button>
|
||||
) : (
|
||||
<Badge className="px-4 py-2 w-full bg-green-600 text-black">
|
||||
<BadgeCheck size={28} />
|
||||
Realizada
|
||||
</Badge>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
apps/web/feactures/surveys/components/survey-response.tsx
Normal file
252
apps/web/feactures/surveys/components/survey-response.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@repo/shadcn/card';
|
||||
import { Checkbox } from '@repo/shadcn/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@repo/shadcn/form';
|
||||
import { Input } from '@repo/shadcn/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@repo/shadcn/radio-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@repo/shadcn/select';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { Question, type SurveyResponse, SurveyTable } from '../schemas/survey';
|
||||
import { useSurveyAnswerMutation } from '../hooks/use-mutation-surveys';
|
||||
|
||||
|
||||
|
||||
interface SurveyResponseProps {
|
||||
survey: SurveyTable;
|
||||
}
|
||||
|
||||
export function SurveyResponse({ survey }: SurveyResponseProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
// Initialize an empty object for each question
|
||||
...Object.fromEntries(
|
||||
survey.questions.map((question) => [question.id, ''])
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: MutateAnswer,
|
||||
} = useSurveyAnswerMutation()
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
setLoading(true);
|
||||
const answers = Object.entries(data).map(([questionId, value]) => ({
|
||||
questionId,
|
||||
value,
|
||||
}));
|
||||
|
||||
const response: SurveyResponse = {
|
||||
surveyId: String(survey.id),
|
||||
answers: answers.map(answer => ({
|
||||
questionId: answer.questionId,
|
||||
value: String(answer.value)
|
||||
})),
|
||||
};
|
||||
try {
|
||||
await MutateAnswer({
|
||||
...response
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Encuesta enviada exitosamente');
|
||||
router.push('/dashboard/encuestas');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Error al enviar la encuesta');
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error('Error al enviar la encuesta');
|
||||
}
|
||||
};
|
||||
|
||||
const renderQuestion = (question: Question) => {
|
||||
switch (question.type) {
|
||||
case 'title':
|
||||
return (
|
||||
<div className="py-4">
|
||||
<h3 className="text-lg font-semibold">{question.content}</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'simple':
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={question.id}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='pb-2'>{question.question}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'multiple_choice':
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={question.id}
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel className='pb-2'>{question.question}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => (
|
||||
<FormField
|
||||
key={option.id}
|
||||
control={form.control}
|
||||
name={`${question.id}.${option.id}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{option.text}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'single_choice':
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={question.id}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='pb-2'>{question.question}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="space-y-2"
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<FormItem
|
||||
key={option.id}
|
||||
className="flex items-center space-x-3"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={option.id} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{option.text}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={question.id}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='pb-2'>{question.question}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Seleccione una opción" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{question.options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=" w-full">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{survey.title}</CardTitle>
|
||||
<CardDescription>{survey.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{survey.questions.map((question) => (
|
||||
<div key={question.id}>{renderQuestion(question)}</div>
|
||||
))}
|
||||
<div className="flex gap-4 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Enviar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/web/feactures/surveys/components/survey.tsx
Normal file
28
apps/web/feactures/surveys/components/survey.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'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} />
|
||||
);
|
||||
}
|
||||
47
apps/web/feactures/surveys/hooks/use-mutation-surveys.ts
Normal file
47
apps/web/feactures/surveys/hooks/use-mutation-surveys.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Survey, SurveyResponse } from "../schemas/survey";
|
||||
import { deleteSurveyAction, saveSurveysAction, saveSurveyAnswer } from "../actions/surveys-actions";
|
||||
|
||||
// Mutation hook remains the same
|
||||
export function useSurveyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: Survey) => saveSurveysAction(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['surveys'] });
|
||||
},
|
||||
// onError: (error) => {
|
||||
// console.error('Error:', error);
|
||||
// },
|
||||
});
|
||||
|
||||
return mutation;
|
||||
}
|
||||
|
||||
export function useDeleteSurvey() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteSurveyAction(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['surveys'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSurveyAnswerMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: SurveyResponse) => saveSurveyAnswer(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['surveys'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
}
|
||||
20
apps/web/feactures/surveys/hooks/use-query-surveys.ts
Normal file
20
apps/web/feactures/surveys/hooks/use-query-surveys.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
import { useSafeQuery } from "@/hooks/use-safe-query";
|
||||
import { getSurveyByIdAction, getSurveysAction, getSurveysForUserAction } from "../actions/surveys-actions";
|
||||
|
||||
|
||||
// Hook for all survesys
|
||||
export function useSurveysQuery(params = {}) {
|
||||
return useSafeQuery(['surveys',params], () => getSurveysAction(params))
|
||||
}
|
||||
|
||||
export function useSurveysForUserQuery(params = {}) {
|
||||
return useSafeQuery(['surveys',params], () => getSurveysForUserAction(params))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function useSurveysByIdQuery(id: number) {
|
||||
return useSafeQuery(['surveys',id], () => getSurveyByIdAction(id))
|
||||
}
|
||||
212
apps/web/feactures/surveys/schemas/survey.ts
Normal file
212
apps/web/feactures/surveys/schemas/survey.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { any, z } from 'zod';
|
||||
|
||||
// Question types
|
||||
export enum QuestionType {
|
||||
TITLE = 'title',
|
||||
SIMPLE = 'simple',
|
||||
MULTIPLE_CHOICE = 'multiple_choice',
|
||||
SINGLE_CHOICE = 'single_choice',
|
||||
SELECT = 'select'
|
||||
}
|
||||
|
||||
// Base question schema
|
||||
const baseQuestionSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.nativeEnum(QuestionType),
|
||||
required: z.boolean().default(false),
|
||||
position: z.number()
|
||||
});
|
||||
|
||||
// Title question
|
||||
export const titleQuestionSchema = baseQuestionSchema.extend({
|
||||
type: z.literal(QuestionType.TITLE),
|
||||
content: z.string()
|
||||
});
|
||||
|
||||
// Simple question (text input)
|
||||
export const simpleQuestionSchema = baseQuestionSchema.extend({
|
||||
type: z.literal(QuestionType.SIMPLE),
|
||||
question: z.string()
|
||||
});
|
||||
|
||||
// Option-based questions
|
||||
const optionSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
});
|
||||
|
||||
// Multiple choice question
|
||||
export const multipleChoiceQuestionSchema = baseQuestionSchema.extend({
|
||||
type: z.literal(QuestionType.MULTIPLE_CHOICE),
|
||||
question: z.string(),
|
||||
options: z.array(optionSchema)
|
||||
});
|
||||
|
||||
// Single choice question
|
||||
export const singleChoiceQuestionSchema = baseQuestionSchema.extend({
|
||||
type: z.literal(QuestionType.SINGLE_CHOICE),
|
||||
question: z.string(),
|
||||
options: z.array(optionSchema)
|
||||
});
|
||||
|
||||
// Select question
|
||||
export const selectQuestionSchema = baseQuestionSchema.extend({
|
||||
type: z.literal(QuestionType.SELECT),
|
||||
question: z.string(),
|
||||
options: z.array(optionSchema)
|
||||
});
|
||||
|
||||
// Union of all question types
|
||||
export const questionSchema = z.discriminatedUnion('type', [
|
||||
titleQuestionSchema,
|
||||
simpleQuestionSchema,
|
||||
multipleChoiceQuestionSchema,
|
||||
singleChoiceQuestionSchema,
|
||||
selectQuestionSchema
|
||||
]);
|
||||
|
||||
// Survey schema
|
||||
export const surveySchema = z.object({
|
||||
id: z.number().optional(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
targetAudience: z.string(),
|
||||
closingDate: z.date().optional(),
|
||||
published: z.boolean(),
|
||||
questions: z.array(questionSchema),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// Survey Answer request schema
|
||||
export const surveyAnswerMutateSchema = z.object({
|
||||
surveyId: z.string(),
|
||||
answers: z.array(
|
||||
z.object({
|
||||
questionId: z.string(),
|
||||
value: z.union([z.string(), z.array(z.string())])
|
||||
})
|
||||
),
|
||||
|
||||
});
|
||||
|
||||
// Types based on schemas
|
||||
export type Question = z.infer<typeof questionSchema>;
|
||||
export type TitleQuestion = z.infer<typeof titleQuestionSchema>;
|
||||
export type SimpleQuestion = z.infer<typeof simpleQuestionSchema>;
|
||||
export type MultipleChoiceQuestion = z.infer<typeof multipleChoiceQuestionSchema>;
|
||||
export type SingleChoiceQuestion = z.infer<typeof singleChoiceQuestionSchema>;
|
||||
export type SelectQuestion = z.infer<typeof selectQuestionSchema>;
|
||||
export type Survey = z.infer<typeof surveySchema>;
|
||||
export type SurveyResponse = z.infer<typeof surveyAnswerMutateSchema>;
|
||||
|
||||
|
||||
export const surveyApiSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
targetAudience: z.string(),
|
||||
closingDate: z.string().transform((str) => str ? new Date(str) : null),
|
||||
published: z.boolean(),
|
||||
questions: z.array(questionSchema),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
export type SurveyTable = z.infer<typeof surveyApiSchema>;
|
||||
|
||||
|
||||
// Api response schemas
|
||||
export const surveysApiResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(surveyApiSchema),
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
// Survey response schema
|
||||
export const surveyAnswerApiResponseSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
surveyId: z.number(),
|
||||
userId: z.number(),
|
||||
answers: z.array(
|
||||
z.object({
|
||||
questionId: z.string(),
|
||||
value: z.union([z.string(), z.array(z.string())])
|
||||
})
|
||||
),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
// Response schemas for the API create, update
|
||||
export const SurveyAnswerMutate = z.object({
|
||||
message: z.string(),
|
||||
data: surveyAnswerApiResponseSchema,
|
||||
});
|
||||
|
||||
// Response schemas for the API create, update
|
||||
export const suveryApiMutationResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: surveyApiSchema,
|
||||
});
|
||||
|
||||
// Response schemas for the API create, update
|
||||
export const suveryResponseDeleteSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
// Schema For User Survey Answer
|
||||
export const surveyAnswerQuerySchema = z.object({
|
||||
surverId: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
created_at: z.string().transform((str) => str ? new Date(str) : null),
|
||||
closingDate: z.string().transform((str) => str ? new Date(str) : null),
|
||||
targetAudience: z.string(),
|
||||
user_id: z.number().nullable(),
|
||||
});
|
||||
|
||||
export const surveyAnswerQuerySchema2 = z.object({
|
||||
surveys: z.any(),
|
||||
answers_surveys: z.any().optional()
|
||||
// surverId: z.number(),
|
||||
// title: z.string(),
|
||||
// description: z.string(),
|
||||
// created_at: z.string().transform((str) => str ? new Date(str) : null),
|
||||
// closingDate: z.string().transform((str) => str ? new Date(str) : null),
|
||||
// targetAudience: z.string(),
|
||||
// user_id: z.number().nullable(),
|
||||
});
|
||||
|
||||
// Api response schemas
|
||||
export const surveysApiResponseForUserSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(surveyAnswerQuerySchema2),
|
||||
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 type SurveyAnswerForUser = z.infer<typeof surveyAnswerQuerySchema2>;
|
||||
6
apps/web/feactures/surveys/schemas/surveys-options.ts
Normal file
6
apps/web/feactures/surveys/schemas/surveys-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const PUBLISHED_TYPES = {
|
||||
published: 'Publicada',
|
||||
draft: 'Borrador',
|
||||
} as const;
|
||||
|
||||
export type PublishedType = keyof typeof PUBLISHED_TYPES;
|
||||
11
apps/web/feactures/surveys/utils/date-utils.ts
Normal file
11
apps/web/feactures/surveys/utils/date-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
|
||||
16
apps/web/feactures/surveys/utils/searchparams.ts
Normal file
16
apps/web/feactures/surveys/utils/searchparams.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
createSearchParamsCache,
|
||||
createSerializer,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
} from 'nuqs/server';
|
||||
|
||||
export const searchParams = {
|
||||
page: parseAsInteger.withDefault(1),
|
||||
limit: parseAsInteger.withDefault(10),
|
||||
q: parseAsString,
|
||||
type: parseAsString,
|
||||
};
|
||||
|
||||
export const searchParamsCache = createSearchParamsCache(searchParams);
|
||||
export const serialize = createSerializer(searchParams);
|
||||
148
apps/web/feactures/users/actions/actions.ts
Normal file
148
apps/web/feactures/users/actions/actions.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
'use server';
|
||||
import { safeFetchApi } from '@/lib/fetch.api';
|
||||
import {
|
||||
surveysApiResponseSchema,
|
||||
CreateUser,
|
||||
UsersMutate,
|
||||
UpdateUser
|
||||
} from '../schemas/users';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
|
||||
export const getProfileAction = async () => {
|
||||
const session = await auth()
|
||||
const id = session?.user?.id
|
||||
|
||||
const [error, response] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
`/users/${id}`,
|
||||
'GET'
|
||||
);
|
||||
if (error) throw new Error(error.message);
|
||||
return response;
|
||||
};
|
||||
|
||||
export const updateProfileAction = async (payload: UpdateUser) => {
|
||||
const { id, ...payloadWithoutId } = payload;
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
`/users/profile/${id}`,
|
||||
'PATCH',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
console.log(payload);
|
||||
if (error) {
|
||||
if (error.message === 'Email already exists') {
|
||||
throw new Error('Ese correo ya está en uso');
|
||||
}
|
||||
// console.error('Error:', error);
|
||||
throw new Error('Error al crear el usuario');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getUsersAction = 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(
|
||||
surveysApiResponseSchema,
|
||||
`/users?${searchParams}`,
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (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 createUserAction = async (payload: CreateUser) => {
|
||||
const { id, confirmPassword, ...payloadWithoutId } = payload;
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
'/users',
|
||||
'POST',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
if (error.message === 'Username already exists') {
|
||||
throw new Error('Ese usuario ya existe');
|
||||
}
|
||||
if (error.message === 'Email already exists') {
|
||||
throw new Error('Ese correo ya está en uso');
|
||||
}
|
||||
// console.error('Error:', error);
|
||||
throw new Error('Error al crear el usuario');
|
||||
}
|
||||
|
||||
return payloadWithoutId;
|
||||
};
|
||||
|
||||
export const updateUserAction = async (payload: UpdateUser) => {
|
||||
try {
|
||||
const { id, ...payloadWithoutId } = payload;
|
||||
|
||||
const [error, data] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
`/users/${id}`,
|
||||
'PATCH',
|
||||
payloadWithoutId,
|
||||
);
|
||||
|
||||
// console.log(data);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
|
||||
throw new Error(error?.message || 'Error al actualizar el usuario');
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteUserAction = async (id: Number) => {
|
||||
const [error] = await safeFetchApi(
|
||||
UsersMutate,
|
||||
`/users/${id}`,
|
||||
'DELETE'
|
||||
)
|
||||
|
||||
console.log(error);
|
||||
|
||||
|
||||
// if (error) throw new Error(error.message || 'Error al eliminar el usuario')
|
||||
|
||||
return true;
|
||||
}
|
||||
222
apps/web/feactures/users/components/admin/create-user-form.tsx
Normal file
222
apps/web/feactures/users/components/admin/create-user-form.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'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 { Input } from '@repo/shadcn/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@repo/shadcn/select';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useCreateUser } from "../../hooks/use-mutation-users";
|
||||
import { CreateUser, createUser } from '../../schemas/users';
|
||||
|
||||
const ROLES = {
|
||||
// 1: 'Superadmin',
|
||||
2: 'Administrador',
|
||||
3: 'autoridad',
|
||||
4: 'Gerente',
|
||||
5: 'Usuario',
|
||||
6: 'Productor',
|
||||
7: 'Organización'
|
||||
}
|
||||
|
||||
interface CreateUserFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
defaultValues?: Partial<CreateUser>;
|
||||
}
|
||||
|
||||
export function CreateUserForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
defaultValues,
|
||||
}: CreateUserFormProps) {
|
||||
const {
|
||||
mutate: saveAccountingAccounts,
|
||||
isPending: isSaving,
|
||||
isError,
|
||||
} = useCreateUser();
|
||||
|
||||
// const { data: AccoutingAccounts } = useSurveyMutation();
|
||||
|
||||
const defaultformValues = {
|
||||
username: defaultValues?.username || '',
|
||||
fullname: defaultValues?.fullname || '',
|
||||
email: defaultValues?.email || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
id: defaultValues?.id,
|
||||
phone: defaultValues?.phone || '',
|
||||
role: defaultValues?.role,
|
||||
}
|
||||
|
||||
const form = useForm<CreateUser>({
|
||||
resolver: zodResolver(createUser),
|
||||
defaultValues: defaultformValues,
|
||||
mode: 'onChange', // Enable real-time validation
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CreateUser) => {
|
||||
|
||||
const formData = data
|
||||
|
||||
saveAccountingAccounts(formData, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (e) => {
|
||||
form.setError('root', {
|
||||
type: 'manual',
|
||||
message: e.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usuario</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fullname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre completo</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Correo</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value?.toString() ?? ''}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirmar Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Rol</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(Number(value))}
|
||||
defaultValue={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="w-full min-w-[200px]">
|
||||
{Object.entries(ROLES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'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, User } from 'lucide-react';
|
||||
import { SurveyTable } from '@/feactures/users/schemas/users';
|
||||
import { useDeleteUser } from '@/feactures/users/hooks/use-mutation-users';
|
||||
import { AccountPlanModal } from '../user-modal';
|
||||
|
||||
interface CellActionProps {
|
||||
data: SurveyTable;
|
||||
}
|
||||
|
||||
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 } = useDeleteUser();
|
||||
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={() => 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>Deshabilitar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Badge } from "@repo/shadcn/badge";
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { CellAction } from './cell-action';
|
||||
import { SurveyTable } from '@/feactures/users/schemas/users';
|
||||
|
||||
export const columns: ColumnDef<SurveyTable>[] = [
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: 'Usuario',
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Correo",
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Rol',
|
||||
},
|
||||
{
|
||||
accessorKey: 'isActive',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("isActive");
|
||||
return (
|
||||
<Badge variant={status == true ? "default" : "secondary"}>
|
||||
{status == true ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => <CellAction data={row.original} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { PUBLISHED_TYPES } from '@/feactures/surveys/schemas/surveys-options';
|
||||
import { searchParams } from '@repo/shadcn/lib/searchparams';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
|
||||
export const TYPE_OPTIONS = Object.entries(PUBLISHED_TYPES).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
export function useSurveyTableFilters() {
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'q',
|
||||
searchParams.q
|
||||
.withOptions({
|
||||
shallow: false,
|
||||
throttleMs: 500, // Add 500ms delay
|
||||
// Removed dedupingInterval as it's not a valid option
|
||||
})
|
||||
.withDefault(''),
|
||||
);
|
||||
|
||||
const [typeFilter, setTypeFilter] = useQueryState(
|
||||
'published',
|
||||
searchParams.q.withOptions({ shallow: false }).withDefault(''),
|
||||
);
|
||||
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
searchParams.page.withDefault(1),
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearchQuery(null);
|
||||
setTypeFilter(null);
|
||||
setPage(1);
|
||||
}, [setSearchQuery, setPage]);
|
||||
|
||||
const isAnyFilterActive = useMemo(() => {
|
||||
return !!searchQuery || !!typeFilter;
|
||||
}, [searchQuery]);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
page,
|
||||
setPage,
|
||||
resetFilters,
|
||||
isAnyFilterActive,
|
||||
typeFilter,
|
||||
setTypeFilter
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { DataTableFilterBox } from '@repo/shadcn/table/data-table-filter-box';
|
||||
import { DataTableSearch } from '@repo/shadcn/table/data-table-search';
|
||||
import {
|
||||
TYPE_OPTIONS,
|
||||
useSurveyTableFilters,
|
||||
} from './use-survey-table-filters';
|
||||
|
||||
export default function UserTableAction() {
|
||||
const {
|
||||
typeFilter,
|
||||
searchQuery,
|
||||
setPage,
|
||||
setTypeFilter,
|
||||
setSearchQuery,
|
||||
} = useSurveyTableFilters();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<DataTableSearch
|
||||
searchKey={searchQuery}
|
||||
searchQuery={searchQuery || ''}
|
||||
setSearchQuery={setSearchQuery}
|
||||
setPage={setPage}
|
||||
/>
|
||||
{/* <DataTableFilterBox
|
||||
filterKey="type"
|
||||
title="Estado"
|
||||
options={TYPE_OPTIONS}
|
||||
setFilterValue={setTypeFilter}
|
||||
filterValue={typeFilter}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
apps/web/feactures/users/components/admin/update-user-form.tsx
Normal file
227
apps/web/feactures/users/components/admin/update-user-form.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'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 { Input } from '@repo/shadcn/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@repo/shadcn/select';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useUpdateUser } from "@/feactures/users/hooks/use-mutation-users";
|
||||
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
|
||||
|
||||
const ROLES = {
|
||||
// 1: 'Superadmin',
|
||||
2: 'Administrador',
|
||||
3: 'autoridad',
|
||||
4: 'Gerente',
|
||||
5: 'Usuario',
|
||||
6: 'Productor',
|
||||
7: 'Organización'
|
||||
}
|
||||
|
||||
interface UserFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
defaultValues?: Partial<UpdateUser>;
|
||||
}
|
||||
|
||||
export function UpdateUserForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
defaultValues,
|
||||
}: UserFormProps) {
|
||||
const {
|
||||
mutate: saveAccountingAccounts,
|
||||
isPending: isSaving,
|
||||
isError,
|
||||
} = useUpdateUser();
|
||||
|
||||
const defaultformValues = {
|
||||
username: defaultValues?.username || '',
|
||||
fullname: defaultValues?.fullname || '',
|
||||
email: defaultValues?.email || '',
|
||||
password: '',
|
||||
id: defaultValues?.id,
|
||||
phone: defaultValues?.phone || '',
|
||||
role: undefined,
|
||||
isActive: defaultValues?.isActive
|
||||
}
|
||||
|
||||
// console.log(defaultValues);
|
||||
|
||||
const form = useForm<UpdateUser>({
|
||||
resolver: zodResolver(updateUser),
|
||||
defaultValues: defaultformValues,
|
||||
mode: 'onChange', // Enable real-time validation
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UpdateUser) => {
|
||||
|
||||
const formData = data
|
||||
|
||||
saveAccountingAccounts(formData, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: () => {
|
||||
form.setError('root', {
|
||||
type: 'manual',
|
||||
message: 'Error al guardar la cuenta contable',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usuario</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fullname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre completo</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Correo</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value?.toString() ?? ''}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nueva Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Rol</FormLabel>
|
||||
<Select onValueChange={(value) => field.onChange(Number(value))}
|
||||
// defaultValue={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecciona un rol" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="w-full min-w-[200px]">
|
||||
{Object.entries(ROLES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Estatus</FormLabel>
|
||||
<Select defaultValue={String(field.value)} onValueChange={(value) => field.onChange(Boolean(value))}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Seleccione un estatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Activo</SelectItem>
|
||||
<SelectItem value="false">Inactivo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
69
apps/web/feactures/users/components/admin/user-modal.tsx
Normal file
69
apps/web/feactures/users/components/admin/user-modal.tsx
Normal 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 { CreateUserForm } from './create-user-form';
|
||||
import { UpdateUserForm } from './update-user-form';
|
||||
|
||||
interface AccountPlanModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultValues?: Partial<AccountPlan>;
|
||||
}
|
||||
|
||||
export function AccountPlanModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultValues,
|
||||
}: AccountPlanModalProps) {
|
||||
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 usuario'
|
||||
: 'Crear usuario'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete los campos para {defaultValues?.id ? 'actualizar' : 'crear'} un usuario
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{defaultValues?.id ? (
|
||||
<UpdateUserForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
defaultValues={defaultValues}
|
||||
/>
|
||||
): (
|
||||
<CreateUserForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
defaultValues={defaultValues}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { DataTable } from '@repo/shadcn/table/data-table';
|
||||
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
|
||||
import { columns } from './surveys-tables/columns';
|
||||
import { useUsersQuery } from '../../hooks/use-query-users';
|
||||
|
||||
interface SurveysAdminListProps {
|
||||
initialPage: number;
|
||||
initialSearch?: string | null;
|
||||
initialLimit: number;
|
||||
initialType?: string | null;
|
||||
}
|
||||
|
||||
export default function UsersAdminList({
|
||||
initialPage,
|
||||
initialSearch,
|
||||
initialLimit,
|
||||
initialType,
|
||||
}: SurveysAdminListProps) {
|
||||
const filters = {
|
||||
page: initialPage,
|
||||
limit: initialLimit,
|
||||
...(initialSearch && { search: initialSearch }),
|
||||
...(initialType && { type: initialType }),
|
||||
};
|
||||
|
||||
const {data, isLoading} = useUsersQuery(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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
apps/web/feactures/users/components/admin/users-header.tsx
Normal file
27
apps/web/feactures/users/components/admin/users-header.tsx
Normal 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 './user-modal';
|
||||
|
||||
export function UsersHeader() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading
|
||||
title="Administración de usuarios"
|
||||
description="Gestiona los usuarios registrados en la plataforma"
|
||||
/>
|
||||
<Button onClick={() => setOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" /> Agregar Usuario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AccountPlanModal open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
apps/web/feactures/users/components/modal-profile.tsx
Normal file
57
apps/web/feactures/users/components/modal-profile.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@repo/shadcn/dialog';
|
||||
import { AccountPlan } from '@/feactures/users/schemas/account-plan.schema';
|
||||
import { ModalForm } from './update-user-form';
|
||||
|
||||
interface AccountPlanModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultValues?: Partial<AccountPlan>;
|
||||
}
|
||||
|
||||
export function AccountPlanModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultValues,
|
||||
}: AccountPlanModalProps) {
|
||||
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>Actualizar Perfil</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete los campos para actualizar sus datos.<br/>Los campos vacios no seran actualizados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ModalForm
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleCancel}
|
||||
defaultValues={defaultValues}
|
||||
/>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
85
apps/web/feactures/users/components/selectList.tsx
Normal file
85
apps/web/feactures/users/components/selectList.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// 'use client';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@repo/shadcn/select';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@repo/shadcn/form';
|
||||
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
28
apps/web/feactures/users/components/survey.tsx
Normal file
28
apps/web/feactures/users/components/survey.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'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} />
|
||||
);
|
||||
}
|
||||
268
apps/web/feactures/users/components/update-user-form.tsx
Normal file
268
apps/web/feactures/users/components/update-user-form.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
'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 { Input } from '@repo/shadcn/input';
|
||||
// import {
|
||||
// Select,
|
||||
// SelectContent,
|
||||
// SelectItem,
|
||||
// SelectTrigger,
|
||||
// SelectValue,
|
||||
// } from '@repo/shadcn/select';
|
||||
import { SelectSearchable } from '@repo/shadcn/select-searchable'
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useUpdateProfile } from "@/feactures/users/hooks/use-mutation-users";
|
||||
import { UpdateUser, updateUser } from '@/feactures/users/schemas/users';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import React from 'react';
|
||||
import { useStateQuery, useMunicipalityQuery, useParishQuery } from '@/feactures/location/hooks/use-query-location';
|
||||
|
||||
interface UserFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
defaultValues?: Partial<UpdateUser>;
|
||||
}
|
||||
|
||||
export function ModalForm({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
defaultValues,
|
||||
}: UserFormProps) {
|
||||
const {
|
||||
mutate: saveAccountingAccounts,
|
||||
isPending: isSaving,
|
||||
isError,
|
||||
} = useUpdateProfile();
|
||||
|
||||
const [state, setState] = React.useState(0);
|
||||
const [municipality, setMunicipality] = React.useState(0);
|
||||
const [parish, setParish] = React.useState(0);
|
||||
|
||||
const [disabledMunicipality, setDisabledMunicipality] = React.useState(true);
|
||||
const [disabledParish, setDisabledParish] = React.useState(true);
|
||||
|
||||
const { data : dataState } = useStateQuery()
|
||||
const { data : dataMunicipality } = useMunicipalityQuery(state)
|
||||
const { data : dataParish } = useParishQuery(municipality)
|
||||
|
||||
const stateOptions = dataState?.data || [{id:0,name:'Sin estados'}]
|
||||
|
||||
const municipalityOptions = Array.isArray(dataMunicipality?.data) && dataMunicipality.data.length > 0
|
||||
? dataMunicipality.data
|
||||
: [{id:0,stateId:0,name:'Sin Municipios'}]
|
||||
// const parishOptions = dataParish?.data || [{id:0,municipalityId:0,name:'Sin Parroquias'}]
|
||||
const parishOptions = Array.isArray(dataParish?.data) && dataParish.data.length > 0
|
||||
? dataParish.data
|
||||
: [{id:0,stateId:0,name:'Sin Parroquias'}]
|
||||
|
||||
|
||||
const defaultformValues = {
|
||||
username: defaultValues?.username || '',
|
||||
fullname: defaultValues?.fullname || '',
|
||||
email: defaultValues?.email || '',
|
||||
password: '',
|
||||
id: defaultValues?.id,
|
||||
phone: defaultValues?.phone || '',
|
||||
role: undefined,
|
||||
isActive: defaultValues?.isActive,
|
||||
state: defaultValues?.state,
|
||||
municipality: defaultValues?.municipality,
|
||||
parish: defaultValues?.parish
|
||||
}
|
||||
|
||||
|
||||
|
||||
// console.log(defaultValues);
|
||||
|
||||
const form = useForm<UpdateUser>({
|
||||
resolver: zodResolver(updateUser),
|
||||
defaultValues: defaultformValues,
|
||||
mode: 'onChange', // Enable real-time validation
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UpdateUser) => {
|
||||
|
||||
const formData = data
|
||||
|
||||
saveAccountingAccounts(formData, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
toast.success('Actualizado exitosamente!');
|
||||
},
|
||||
onError: (e) => {
|
||||
form.setError('root', {
|
||||
type: 'manual',
|
||||
message: e.message,
|
||||
});
|
||||
// toast.error(e.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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="fullname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre completo</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Correo</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Teléfono</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value?.toString() ?? ''}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Estado</FormLabel>
|
||||
|
||||
<SelectSearchable
|
||||
options={
|
||||
stateOptions?.map((item) => ({
|
||||
value: item.id.toString(),
|
||||
label: item.name,
|
||||
})) || []
|
||||
}
|
||||
onValueChange={(value : any) =>
|
||||
{field.onChange(Number(value)); setState(value); setDisabledMunicipality(false); setDisabledParish(true)}
|
||||
}
|
||||
placeholder="Selecciona un estado"
|
||||
defaultValue={field.value?.toString()}
|
||||
// disabled={readOnly}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="municipality"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Municipio</FormLabel>
|
||||
|
||||
<SelectSearchable
|
||||
options={
|
||||
municipalityOptions?.map((item) => ({
|
||||
value: item.id.toString(),
|
||||
label: item.name,
|
||||
})) || []
|
||||
}
|
||||
onValueChange={(value : any) =>
|
||||
{field.onChange(Number(value)); setMunicipality(value); setDisabledParish(false)}
|
||||
}
|
||||
placeholder="Selecciona un Municipio"
|
||||
defaultValue={field.value?.toString()}
|
||||
disabled={disabledMunicipality}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parish"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Parroquia</FormLabel>
|
||||
|
||||
<SelectSearchable
|
||||
options={
|
||||
parishOptions?.map((item) => ({
|
||||
value: item.id.toString(),
|
||||
label: item.name,
|
||||
})) || []
|
||||
}
|
||||
onValueChange={(value : any) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
placeholder="Selecciona una Parroquia"
|
||||
defaultValue={field.value?.toString()}
|
||||
disabled={disabledParish}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nueva Contraseña</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field}/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
75
apps/web/feactures/users/components/user-profile.tsx
Normal file
75
apps/web/feactures/users/components/user-profile.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
import { useUserByProfile } from '@/feactures/users/hooks/use-query-users';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { Edit, Edit2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AccountPlanModal } from './modal-profile';
|
||||
|
||||
export function Profile() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = useUserByProfile();
|
||||
|
||||
// console.log("🎯 data:", data);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={() => setOpen(true)} size="sm">
|
||||
<Edit2 className="mr-2 h-4 w-4" /> Editar Perfil
|
||||
</Button>
|
||||
|
||||
<AccountPlanModal open={open} onOpenChange={setOpen} defaultValues={data?.data}/>
|
||||
|
||||
<h2 className='mt-3 mb-1'>Datos del usuario</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Usuario:</p>
|
||||
<p>{data?.data.username || 'Sin Nombre de Usuario'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Rol:</p>
|
||||
<p>{data?.data.role || 'Sin Rol'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 className='mt-3 mb-1'>Información personal</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Nombre completo:</p>
|
||||
<p>{data?.data.fullname || 'Sin nombre y apellido'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Correo:</p>
|
||||
<p>{data?.data.email || 'Sin correo'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Teléfono:</p>
|
||||
<p>{data?.data.phone || 'Sin teléfono'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2 className='mt-3 mb-1'>Información de ubicación</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Estado:</p>
|
||||
<p>{data?.data.state || 'Sin Estado'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Municipio:</p>
|
||||
<p>{data?.data.municipality || 'Sin Municipio'}</p>
|
||||
</section>
|
||||
|
||||
<section className='border bg-muted p-2 rounded-md'>
|
||||
<p className='font-bold text-lg'>Parroquia:</p>
|
||||
<p>{data?.data.parish || 'Sin Parroquia'}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
45
apps/web/feactures/users/hooks/use-mutation-users.ts
Normal file
45
apps/web/feactures/users/hooks/use-mutation-users.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CreateUser, UpdateUser } from "../schemas/users";
|
||||
import { updateUserAction, createUserAction, deleteUserAction, updateProfileAction } from "../actions/actions";
|
||||
|
||||
// Create mutation
|
||||
export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: CreateUser) => createUserAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
// onError: (e) => console.error('Error:', e),
|
||||
})
|
||||
return mutation
|
||||
}
|
||||
|
||||
// Update mutation
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UpdateUser) => updateUserAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
onError: (e) => console.error('Error:', e)
|
||||
})
|
||||
return mutation;
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UpdateUser) => updateProfileAction(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
// onError: (e) => console.error('Error:', e)
|
||||
})
|
||||
return mutation;
|
||||
}
|
||||
|
||||
// Delete mutation
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteUserAction(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
onError: (e) => console.error('Error:', e)
|
||||
})
|
||||
}
|
||||
12
apps/web/feactures/users/hooks/use-query-surveys.ts
Normal file
12
apps/web/feactures/users/hooks/use-query-surveys.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
import { useSafeQuery } from "@/hooks/use-safe-query";
|
||||
import { getUsersAction,getProfileAction} from "../actions/actions";
|
||||
|
||||
// Hook for users
|
||||
export function useUsersQuery(params = {}) {
|
||||
return useSafeQuery(['users',params], () => getUsersAction(params))
|
||||
}
|
||||
|
||||
export function useUserByProfile() {
|
||||
return useSafeQuery(['users'], () => getProfileAction())
|
||||
}
|
||||
12
apps/web/feactures/users/hooks/use-query-users.ts
Normal file
12
apps/web/feactures/users/hooks/use-query-users.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
import { useSafeQuery } from "@/hooks/use-safe-query";
|
||||
import { getUsersAction,getProfileAction} from "../actions/actions";
|
||||
|
||||
// Hook for users
|
||||
export function useUsersQuery(params = {}) {
|
||||
return useSafeQuery(['users',params], () => getUsersAction(params))
|
||||
}
|
||||
|
||||
export function useUserByProfile() {
|
||||
return useSafeQuery(['users'], () => getProfileAction())
|
||||
}
|
||||
19
apps/web/feactures/users/schemas/account-plan-options.ts
Normal file
19
apps/web/feactures/users/schemas/account-plan-options.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const ACCOUNT_TYPES = {
|
||||
activo: 'Activo',
|
||||
pasivo: 'Pasivo',
|
||||
patrimonio: 'Patrimonio',
|
||||
ingreso: 'Ingreso',
|
||||
gasto: 'Gasto',
|
||||
costo: 'Costo',
|
||||
cuenta_orden: 'Cuenta de Orden',
|
||||
} as const;
|
||||
|
||||
export const ACCOUNT_LEVELS = {
|
||||
1: 'Nivel 1 - Cuenta Principal',
|
||||
2: 'Nivel 2 - Subcuenta',
|
||||
3: 'Nivel 3 - Cuenta Detallada',
|
||||
4: 'Nivel 4 - Cuenta Auxiliar',
|
||||
} as const;
|
||||
|
||||
export type AccountType = keyof typeof ACCOUNT_TYPES;
|
||||
export type AccountLevel = keyof typeof ACCOUNT_LEVELS;
|
||||
83
apps/web/feactures/users/schemas/account-plan.schema.ts
Normal file
83
apps/web/feactures/users/schemas/account-plan.schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const accountPlanSchema = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
savingBankId: z.number(),
|
||||
code: z
|
||||
.string()
|
||||
.min(1, 'El código es requerido')
|
||||
.max(50, 'El código no puede tener más de 50 caracteres')
|
||||
.regex(/^[\d.]+$/, 'El código debe contener solo números y puntos'),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'El nombre es requerido')
|
||||
.max(100, 'El nombre no puede tener más de 100 caracteres'),
|
||||
type: z.enum(
|
||||
[
|
||||
'activo',
|
||||
'pasivo',
|
||||
'patrimonio',
|
||||
'ingreso',
|
||||
'gasto',
|
||||
'costo',
|
||||
'cuenta_orden',
|
||||
],
|
||||
{
|
||||
required_error: 'El tipo de cuenta es requerido',
|
||||
invalid_type_error: 'Tipo de cuenta inválido',
|
||||
},
|
||||
),
|
||||
description: z.string().optional().nullable(),
|
||||
level: z
|
||||
.number()
|
||||
.min(1, 'El nivel debe ser mayor a 0')
|
||||
.max(4, 'El nivel no puede ser mayor a 4'),
|
||||
parent_account_id: z.number().nullable(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.level > 1 && !data.parent_account_id) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Las cuentas de nivel superior a 1 requieren una cuenta padre',
|
||||
path: ['parent_account_id'],
|
||||
},
|
||||
);
|
||||
|
||||
export type AccountPlan = z.infer<typeof accountPlanSchema>;
|
||||
|
||||
// Response schemas for the API
|
||||
export const accountPlanResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: accountPlanSchema,
|
||||
});
|
||||
|
||||
export const accountPlanDeleteResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const accountPlanListResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(accountPlanSchema),
|
||||
});
|
||||
|
||||
export const accountPlanPaginationResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(accountPlanSchema),
|
||||
meta: z.object({
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
totalCount: z.number(),
|
||||
totalPages: z.number(),
|
||||
hasNextPage: z.boolean(),
|
||||
hasPreviousPage: z.boolean(),
|
||||
nextPage: z.number().nullable(),
|
||||
previousPage: z.number().nullable(),
|
||||
}),
|
||||
});
|
||||
6
apps/web/feactures/users/schemas/surveys-options.ts
Normal file
6
apps/web/feactures/users/schemas/surveys-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const PUBLISHED_TYPES = {
|
||||
published: 'Publicada',
|
||||
draft: 'Borrador',
|
||||
} as const;
|
||||
|
||||
export type PublishedType = keyof typeof PUBLISHED_TYPES;
|
||||
67
apps/web/feactures/users/schemas/users.ts
Normal file
67
apps/web/feactures/users/schemas/users.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type SurveyTable = z.infer<typeof user>;
|
||||
export type CreateUser = z.infer<typeof createUser>;
|
||||
export type UpdateUser = z.infer<typeof updateUser>;
|
||||
|
||||
export const user = z.object({
|
||||
id: z.number().optional(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
fullname: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
role: z.string(),
|
||||
state: z.string().optional().nullable(),
|
||||
municipality: z.string().optional().nullable(),
|
||||
parish: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const createUser = z.object({
|
||||
id: z.number().optional(),
|
||||
username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }),
|
||||
password: z.string().min(8, { message: "Debe de tener 8 o más caracteres" }),
|
||||
email: z.string().email({ message: "Correo no válido" }),
|
||||
fullname: z.string(),
|
||||
phone: z.string(),
|
||||
confirmPassword: z.string(),
|
||||
role: z.number()
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'La contraseña no coincide',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export const updateUser = z.object({
|
||||
id: z.number(),
|
||||
username: z.string().min(5, { message: "Debe de tener 5 o más caracteres" }).or(z.literal('')),
|
||||
password: z.string().min(6, { message: "Debe de tener 6 o más caracteres" }).or(z.literal('')),
|
||||
email: z.string().email({ message: "Correo no válido" }).or(z.literal('')),
|
||||
fullname: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
role: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
state: z.number().optional().nullable(),
|
||||
municipality: z.number().optional().nullable(),
|
||||
parish: z.number().optional().nullable(),
|
||||
})
|
||||
|
||||
export const surveysApiResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
data: z.array(user),
|
||||
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 UsersMutate = z.object({
|
||||
message: z.string(),
|
||||
data: user,
|
||||
})
|
||||
11
apps/web/feactures/users/utils/date-utils.ts
Normal file
11
apps/web/feactures/users/utils/date-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
|
||||
16
apps/web/feactures/users/utils/searchparams.ts
Normal file
16
apps/web/feactures/users/utils/searchparams.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
createSearchParamsCache,
|
||||
createSerializer,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
} from 'nuqs/server';
|
||||
|
||||
export const searchParams = {
|
||||
page: parseAsInteger.withDefault(1),
|
||||
limit: parseAsInteger.withDefault(10),
|
||||
q: parseAsString,
|
||||
type: parseAsString,
|
||||
};
|
||||
|
||||
export const searchParamsCache = createSearchParamsCache(searchParams);
|
||||
export const serialize = createSerializer(searchParams);
|
||||
Reference in New Issue
Block a user