base con autenticacion, registro, modulo encuestas
This commit is contained in:
4
apps/web/.env_template
Normal file
4
apps/web/.env_template
Normal file
@@ -0,0 +1,4 @@
|
||||
AUTH_URL = http://localhost:3000
|
||||
AUTH_SECRET=wWgIwkHIGr28ydIUPsgVGNUNxXQ+brg1XXtA8PfjJjAEPJLDP2UTghWL8aE=
|
||||
API_URL=http://localhost:8000
|
||||
|
||||
36
apps/web/.gitignore
vendored
Normal file
36
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env files (can opt-in for commiting if needed)
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
49
apps/web/README.md
Normal file
49
apps/web/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
### Frontend
|
||||
|
||||
`SafeFetch`
|
||||
|
||||
```ts
|
||||
import z, { ZodSchema } from 'zod';
|
||||
import { env } from './env';
|
||||
|
||||
export const safeFetch = async <T extends ZodSchema<unknown>>(
|
||||
schema: T,
|
||||
url: URL | RequestInfo,
|
||||
init?: RequestInit,
|
||||
): Promise<[string | null, z.TypeOf<T>]> => {
|
||||
const response: Response = await fetch(`${env.API_URL}${url}`, init);
|
||||
const res = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return [
|
||||
`HTTP error! Status: ${response.status} - ${response.statusText}`,
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
const validateFields = schema.safeParse(res);
|
||||
|
||||
if (!validateFields.success) {
|
||||
console.log(res);
|
||||
console.log('Validation errors:', validateFields.error);
|
||||
return [`Validation error: ${validateFields.error.message}`, null];
|
||||
}
|
||||
|
||||
return [null, validateFields.data];
|
||||
};
|
||||
```
|
||||
|
||||
`How to use SafeFetch?`
|
||||
|
||||
```ts
|
||||
export const getAllUsers = async (): Promise<GetAllUsers> => {
|
||||
const [isError, data] = await safeFetch(GetAllUsersSchema, '/users', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (isError)
|
||||
return {
|
||||
data: [],
|
||||
};
|
||||
return data;
|
||||
};
|
||||
```
|
||||
14
apps/web/app/(auth)/page.tsx
Normal file
14
apps/web/app/(auth)/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { LoginForm } from '@/feactures/auth/components/sigin-view';
|
||||
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-3xl">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
export default Page;
|
||||
2
apps/web/app/api/auth/[...nextauth]/route.ts
Normal file
2
apps/web/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from '@/lib/auth'; // Referring to the auth.ts we just created
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,13 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { SurveyBuilder } from '@/feactures/surveys/components/admin/survey-builder';
|
||||
|
||||
export default function CreateSurveyPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold mb-6">Crear Nueva Encuesta</h1>
|
||||
<SurveyBuilder />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import PageContainer from "@/components/layout/page-container";
|
||||
import { SurveyBuilder } from "@/feactures/surveys/components/admin/survey-builder";
|
||||
|
||||
export default function EditSurveyPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold mb-6">Editar Encuesta</h1>
|
||||
<SurveyBuilder />
|
||||
</div>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
37
apps/web/app/dashboard/administracion/encuestas/page.tsx
Normal file
37
apps/web/app/dashboard/administracion/encuestas/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import SurveysAdminList from '@/feactures/surveys/components/admin/surveys-admin-list';
|
||||
import { SurveysHeader } from '@/feactures/surveys/components/admin/surveys-header';
|
||||
import SurveysTableAction from '@/feactures/surveys/components/admin/surveys-tables/survey-table-action';
|
||||
import { searchParamsCache, serialize } from '@/feactures/surveys/utils/searchparams';
|
||||
import { SearchParams } from 'nuqs';
|
||||
|
||||
type pageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
|
||||
export default async function SurveyAdminPage(props: pageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
searchParamsCache.parse(searchParams);
|
||||
const key = serialize({ ...searchParams });
|
||||
|
||||
const page = Number(searchParamsCache.get('page')) || 1;
|
||||
const search = searchParamsCache.get('q');
|
||||
const pageLimit = Number(searchParamsCache.get('limit')) || 10;
|
||||
const type = searchParamsCache.get('type');
|
||||
|
||||
return (
|
||||
<PageContainer scrollable={false}>
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<SurveysHeader />
|
||||
<SurveysTableAction />
|
||||
<SurveysAdminList
|
||||
initialPage={page}
|
||||
initialSearch={search}
|
||||
initialLimit={pageLimit}
|
||||
initialType={type}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
37
apps/web/app/dashboard/administracion/usuario/page.tsx
Normal file
37
apps/web/app/dashboard/administracion/usuario/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import UsersAdminList from '@/feactures/users/components/admin/users-admin-list';
|
||||
import { UsersHeader } from '@/feactures/users/components/admin/users-header';
|
||||
import UsersTableAction from '@/feactures/users/components/admin/surveys-tables/users-table-action';
|
||||
import { searchParamsCache, serialize } from '@/feactures/users/utils/searchparams';
|
||||
import { SearchParams } from 'nuqs';
|
||||
|
||||
type pageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
|
||||
export default async function SurveyAdminPage(props: pageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
searchParamsCache.parse(searchParams);
|
||||
const key = serialize({ ...searchParams });
|
||||
|
||||
const page = Number(searchParamsCache.get('page')) || 1;
|
||||
const search = searchParamsCache.get('q');
|
||||
const pageLimit = Number(searchParamsCache.get('limit')) || 10;
|
||||
const type = searchParamsCache.get('type');
|
||||
|
||||
return (
|
||||
<PageContainer scrollable={false}>
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<UsersHeader />
|
||||
<UsersTableAction />
|
||||
<UsersAdminList
|
||||
initialPage={page}
|
||||
initialSearch={search}
|
||||
initialLimit={pageLimit}
|
||||
initialType={type}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
19
apps/web/app/dashboard/configuraciones/caja-ahorro/page.tsx
Normal file
19
apps/web/app/dashboard/configuraciones/caja-ahorro/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
En mantenimiento
|
||||
</div>
|
||||
|
||||
</PageContainer>
|
||||
// <div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||
// <div className="flex w-full max-w-sm flex-col gap-6">
|
||||
|
||||
// </div>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
24
apps/web/app/dashboard/encuestas/[id]/responder/page.tsx
Normal file
24
apps/web/app/dashboard/encuestas/[id]/responder/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { getSurveyByIdAction } from '@/feactures/surveys/actions/surveys-actions';
|
||||
import { SurveyResponse } from '@/feactures/surveys/components/survey-response';
|
||||
|
||||
// La función ahora recibe 'params' con el parámetro dinámico 'id'
|
||||
export default async function SurveyResponsePage({ params }: { params: { id: string } }) {
|
||||
const { id } = await params; // Obtienes el id desde los params de la URL
|
||||
|
||||
if (!id || id === '') {
|
||||
// Maneja el caso en el que no se proporciona un id
|
||||
return null;
|
||||
}
|
||||
|
||||
// Llamas a la función pasando el id dinámico
|
||||
const data = await getSurveyByIdAction(Number(id));
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<SurveyResponse survey={data?.data!} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
21
apps/web/app/dashboard/encuestas/page.tsx
Normal file
21
apps/web/app/dashboard/encuestas/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { SurveyList } from '@/feactures/surveys/components/survey-list';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Encuestas - Fondemi',
|
||||
description: 'Listado de encuestas disponibles',
|
||||
};
|
||||
|
||||
export default function SurveysPage() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold mb-6">Encuestas Disponibles</h1>
|
||||
<SurveyList />
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
);
|
||||
}
|
||||
19
apps/web/app/dashboard/estadisticas/encuestas/page.tsx
Normal file
19
apps/web/app/dashboard/estadisticas/encuestas/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { SurveyStatistics } from '@/feactures/statistics/components/survey-statistics';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Estadísticas de Encuestas - Fondemi',
|
||||
description: 'Análisis y estadísticas de las encuestas realizadas',
|
||||
};
|
||||
|
||||
export default function SurveyStatisticsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold mb-6">Estadísticas de Encuestas</h1>
|
||||
<SurveyStatistics />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
11
apps/web/app/dashboard/inicio/page.tsx
Normal file
11
apps/web/app/dashboard/inicio/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
return (
|
||||
// <PageContainer>
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<img src="../logo.png" alt="Image" className="w-1/4"/>
|
||||
</div>
|
||||
// </PageContainer>
|
||||
);
|
||||
}
|
||||
31
apps/web/app/dashboard/layout.tsx
Normal file
31
apps/web/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar';
|
||||
import Header from '@/components/layout/header';
|
||||
import { SidebarInset, SidebarProvider } from '@repo/shadcn/sidebar';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dashboard',
|
||||
description: 'Sistema integral para Cajas de Ahorro',
|
||||
};
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Persisting the sidebar state in the cookie.
|
||||
const cookieStore = await cookies();
|
||||
//const defaultOpen = cookieStore.get('sidebar:state')?.value === 'false';
|
||||
return (
|
||||
<SidebarProvider defaultOpen={true}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
{/* page main content */}
|
||||
{children}
|
||||
{/* page main content ends */}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
12
apps/web/app/dashboard/page.tsx
Normal file
12
apps/web/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Dashboard() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return redirect('/');
|
||||
} else {
|
||||
redirect('/dashboard/inicio');
|
||||
}
|
||||
}
|
||||
17
apps/web/app/dashboard/profile/page.tsx
Normal file
17
apps/web/app/dashboard/profile/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import PageContainer from "@/components/layout/page-container";
|
||||
import {Profile} from '@/feactures/users/components/user-profile'
|
||||
|
||||
|
||||
export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold">Perfil</h1>
|
||||
<p className="text-muted-foreground mb-6">Aquí puede ver y editar sus datos de perfil</p>
|
||||
<Profile />
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
);
|
||||
}
|
||||
37
apps/web/app/error.tsx
Normal file
37
apps/web/app/error.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { RotateCw } from '@repo/shadcn/icon';
|
||||
import { cn } from '@repo/shadcn/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useTransition } from 'react';
|
||||
|
||||
const Error = ({ error, reset }: { error: Error; reset: () => void }) => {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col min-h-dvh gap-9 items-center justify-center">
|
||||
<p className="font-semibold">
|
||||
Oh no, something went wrong... maybe refresh?
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
reset();
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
<RotateCw className={cn('size-5', isPending && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Error;
|
||||
BIN
apps/web/app/fonts/GeistMonoVF.woff
Normal file
BIN
apps/web/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
apps/web/app/fonts/GeistVF.woff
Normal file
BIN
apps/web/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
64
apps/web/app/layout.tsx
Normal file
64
apps/web/app/layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Providers from '@/components/layout/providers';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { cn } from '@repo/shadcn/lib/utils';
|
||||
import '@repo/shadcn/shadcn.css';
|
||||
import { Metadata } from 'next';
|
||||
import localFont from 'next/font/local';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
const GeistSans = localFont({
|
||||
src: './fonts/GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
const GeistMono = localFont({
|
||||
src: './fonts/GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
metadataBase: new URL('https://turbo-npn.onrender.com'),
|
||||
title: {
|
||||
default: 'Caja de Ahorro',
|
||||
template: '%s | Caja de Ahorro',
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Caja de Ahorro',
|
||||
description: 'Sistema integral para cajas de ahorro',
|
||||
url: 'https://turbo-npn.onrender.com',
|
||||
images: [
|
||||
{
|
||||
url: '/og-bg.png',
|
||||
width: 1200,
|
||||
height: 628,
|
||||
alt: 'Turbo NPN Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Metadata;
|
||||
|
||||
const RootLayout = async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
}>) => {
|
||||
const session = await auth();
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(GeistMono.variable, GeistSans.variable, 'antialiased')}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<NextTopLoader showSpinner={false} />
|
||||
<Providers session={session}>
|
||||
<Toaster />
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
29
apps/web/app/not-found.tsx
Normal file
29
apps/web/app/not-found.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { RotateCw } from '@repo/shadcn/icon';
|
||||
import { cn } from '@repo/shadcn/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
const NotFound = () => {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
return (
|
||||
<div className="w-full flex flex-col min-h-dvh gap-9 items-center justify-center">
|
||||
<h1 className="font-semibold text-base">404 | Notfound</h1>
|
||||
<p className="font-semibold">{"Oh no! This page doesn't exist."}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
router.push('/');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Return Home
|
||||
<RotateCw className={cn('size-5', isPending && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
BIN
apps/web/app/og/mono.ttf
Normal file
BIN
apps/web/app/og/mono.ttf
Normal file
Binary file not shown.
62
apps/web/app/og/route.tsx
Normal file
62
apps/web/app/og/route.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const title = searchParams.get('title');
|
||||
const description = searchParams.get('description');
|
||||
const font = fetch(new URL('./mono.ttf', import.meta.url)).then((res) =>
|
||||
res.arrayBuffer(),
|
||||
);
|
||||
const fontData = await font;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw="flex h-full w-full bg-black text-white"
|
||||
style={{ fontFamily: 'Geist Sans' }}
|
||||
>
|
||||
<div tw="flex border absolute border-stone-700 border-dashed inset-y-0 left-16 w-[1px]" />
|
||||
<div tw="flex border absolute border-stone-700 border-dashed inset-y-0 right-16 w-[1px]" />
|
||||
<div tw="flex border absolute border-stone-700 inset-x-0 h-[1px] top-16" />
|
||||
<div tw="flex border absolute border-stone-700 inset-x-0 h-[1px] bottom-16" />
|
||||
<div tw="flex absolute flex-row bottom-24 right-24 text-white"></div>
|
||||
<div tw="flex flex-col absolute w-[896px] justify-center inset-32">
|
||||
<div
|
||||
tw="tracking-tight flex-grow-1 flex flex-col justify-center leading-[1.1]"
|
||||
style={{
|
||||
textWrap: 'balance',
|
||||
fontWeight: 600,
|
||||
fontSize: title && title.length > 20 ? 64 : 80,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
tw="text-[40px] leading-[1.5] flex-grow-1 text-stone-400"
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{`${description?.slice(0, 100)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 628,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Jetbrains Mono',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
48
apps/web/app/opengraph-image.tsx
Normal file
48
apps/web/app/opengraph-image.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// Image metadata
|
||||
export const alt = `Opengraph Image`;
|
||||
export const size = {
|
||||
width: 800,
|
||||
height: 400,
|
||||
};
|
||||
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
padding: 70,
|
||||
color: 'red',
|
||||
backgroundImage: 'linear-gradient(to right, #334d50, #cbcaa5)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`http://localhost:3000/og-logo.png`}
|
||||
alt="opengraph logo"
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
},
|
||||
);
|
||||
}
|
||||
13
apps/web/app/register/page.tsx
Normal file
13
apps/web/app/register/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { LoginForm } from "@/feactures/auth/components/signup-view";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-3xl">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page;
|
||||
20
apps/web/components.json
Normal file
20
apps/web/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/shadcn/src/shadcn.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks",
|
||||
"ui": "../../packages/shadcn/components/ui",
|
||||
"utils": "../../packages/shadcn/lib/utils"
|
||||
}
|
||||
}
|
||||
42
apps/web/components/breadcrumbs.tsx
Normal file
42
apps/web/components/breadcrumbs.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import { useBreadcrumbs } from '@/hooks/use-breadcrumbs';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@repo/shadcn/breadcrumb';
|
||||
import { Slash } from 'lucide-react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const items = useBreadcrumbs();
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.title}>
|
||||
{index !== items.length - 1 && (
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink href={item.link}>{item.title}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{index < items.length - 1 && (
|
||||
<BreadcrumbSeparator className="hidden md:block">
|
||||
<Slash />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
{index === items.length - 1 && (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
92
apps/web/components/icons.tsx
Normal file
92
apps/web/components/icons.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircuitBoardIcon,
|
||||
Command,
|
||||
CreditCard,
|
||||
File,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Image,
|
||||
Laptop,
|
||||
LayoutDashboardIcon,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LucideIcon,
|
||||
LucideProps,
|
||||
LucideShoppingBag,
|
||||
Moon,
|
||||
MoreVertical,
|
||||
Pizza,
|
||||
Plus,
|
||||
Settings,
|
||||
SunMedium,
|
||||
Trash,
|
||||
Twitter,
|
||||
User,
|
||||
UserCircle2Icon,
|
||||
UserPen,
|
||||
UserX2Icon,
|
||||
X,
|
||||
Settings2,
|
||||
ChartColumn,
|
||||
NotepadText
|
||||
} from 'lucide-react';
|
||||
|
||||
export type Icon = LucideIcon;
|
||||
|
||||
export const Icons = {
|
||||
dashboard: LayoutDashboardIcon,
|
||||
logo: Command,
|
||||
login: LogIn,
|
||||
close: X,
|
||||
product: LucideShoppingBag,
|
||||
spinner: Loader2,
|
||||
kanban: CircuitBoardIcon,
|
||||
chevronLeft: ChevronLeft,
|
||||
chevronRight: ChevronRight,
|
||||
trash: Trash,
|
||||
employee: UserX2Icon,
|
||||
post: FileText,
|
||||
page: File,
|
||||
userPen: UserPen,
|
||||
user2: UserCircle2Icon,
|
||||
media: Image,
|
||||
settings: Settings,
|
||||
billing: CreditCard,
|
||||
ellipsis: MoreVertical,
|
||||
add: Plus,
|
||||
warning: AlertTriangle,
|
||||
user: User,
|
||||
arrowRight: ArrowRight,
|
||||
help: HelpCircle,
|
||||
pizza: Pizza,
|
||||
sun: SunMedium,
|
||||
moon: Moon,
|
||||
laptop: Laptop,
|
||||
settings2: Settings2,
|
||||
chartColumn: ChartColumn,
|
||||
notepadText: NotepadText,
|
||||
gitHub: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
focusable='false'
|
||||
data-prefix='fab'
|
||||
data-icon='github'
|
||||
role='img'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 496 512'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill='currentColor'
|
||||
d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z'
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
twitter: Twitter,
|
||||
check: Check
|
||||
};
|
||||
13
apps/web/components/layout/ThemeToggle/theme-provider.tsx
Normal file
13
apps/web/components/layout/ThemeToggle/theme-provider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
ThemeProviderProps
|
||||
} from 'next-themes';
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
37
apps/web/components/layout/ThemeToggle/theme-toggle.tsx
Normal file
37
apps/web/components/layout/ThemeToggle/theme-toggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
import { MoonIcon, SunIcon } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@repo/shadcn/dropdown-menu';
|
||||
type CompProps = {};
|
||||
export default function ThemeToggle({}: CompProps) {
|
||||
const { setTheme } = useTheme();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/layout/app-sidebar.tsx
Normal file
58
apps/web/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { NavMain as ConfigMain, NavMain as GeneralMain, NavMain as AdministrationMain, NavMain as StatisticsMain, } from '@/components/nav-main';
|
||||
import { GeneralItems, AdministrationItems, StatisticsItems } from '@/constants/data';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@repo/shadcn/sidebar';
|
||||
import { GalleryVerticalEnd } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
// import { NavItem } from '@/types';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
|
||||
export const company = {
|
||||
name: 'Sistema',
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: 'FONDEMI',
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { data: session } = useSession();
|
||||
const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol :'';
|
||||
// console.log(AdministrationItems[0]?.role);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<div className="flex gap-2 py-2 text-sidebar-accent-foreground">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<company.logo className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{company.name}</span>
|
||||
<span className="truncate text-xs">{company.plan}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<GeneralMain titleGroup={'General'} items={GeneralItems} role={userRole}/>
|
||||
|
||||
{StatisticsItems[0]?.role?.includes(userRole) &&
|
||||
<StatisticsMain titleGroup={'Estadisticas'} items={StatisticsItems} role={userRole}/>
|
||||
}
|
||||
|
||||
{AdministrationItems[0]?.role?.includes(userRole) &&
|
||||
<AdministrationMain titleGroup={'Administracion'} items={AdministrationItems} role={userRole}/>
|
||||
}
|
||||
{/* <NavProjects projects={data.projects} /> */}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
25
apps/web/components/layout/header.tsx
Normal file
25
apps/web/components/layout/header.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Separator } from '@repo/shadcn/separator';
|
||||
import { SidebarTrigger } from '@repo/shadcn/sidebar';
|
||||
import { Breadcrumbs } from '../breadcrumbs';
|
||||
import ThemeToggle from './ThemeToggle/theme-toggle';
|
||||
import { UserNav } from './user-nav';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 ml-auto">
|
||||
<ThemeToggle />
|
||||
<UserNav />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
22
apps/web/components/layout/page-container.tsx
Normal file
22
apps/web/components/layout/page-container.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ScrollArea } from '@repo/shadcn/scroll-area';
|
||||
import React from 'react';
|
||||
|
||||
export default function PageContainer({
|
||||
children,
|
||||
scrollable = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{scrollable ? (
|
||||
<ScrollArea className="h-[calc(100dvh-52px)]">
|
||||
<div className="flex flex-1 p-4 md:px-6">{children}</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-1 p-4 md:px-6">{children}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/layout/providers.tsx
Normal file
48
apps/web/components/layout/providers.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
import { ThemeProvider } from '@repo/shadcn/themes-provider';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SessionProvider, SessionProviderProps } from 'next-auth/react';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type ProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
gcTime: 60 * 60 * 1000, // 1 hora para garbage collection
|
||||
staleTime: 60 * 60 * 1000, // 1 hora para considerar datos obsoletos
|
||||
refetchOnWindowFocus: false, // No recargar al enfocar la ventana
|
||||
refetchOnMount: true, // Recargar al montar el componente
|
||||
},
|
||||
},
|
||||
});
|
||||
const Providers = ({
|
||||
session,
|
||||
children,
|
||||
}: {
|
||||
session: SessionProviderProps['session'];
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange={false}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<SessionProvider session={session}>{children}</SessionProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Providers;
|
||||
52
apps/web/components/layout/user-nav.tsx
Normal file
52
apps/web/components/layout/user-nav.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@repo/shadcn/avatar';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@repo/shadcn/dropdown-menu';
|
||||
import { signOut, useSession } from 'next-auth/react';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export function UserNav() {
|
||||
const { data: session } = useSession();
|
||||
if (session) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={''} alt={session.user?.fullname ?? ''} />
|
||||
<AvatarFallback>{session.user?.fullname?.[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{session.user?.fullname}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{session.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={()=> redirect('/dashboard/profile')}>Perfil</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut()}>
|
||||
Cerrar Sessión
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
50
apps/web/components/modal/alert-modal.tsx
Normal file
50
apps/web/components/modal/alert-modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
import { Button } from '@repo/shadcn/button';
|
||||
import { Modal } from '@repo/shadcn/modal';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const AlertModal: React.FC<AlertModalProps> = ({
|
||||
title = 'Are you sure?',
|
||||
description = 'This action cannot be undone.',
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
description={description}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex w-full items-center justify-end space-x-2 pt-6">
|
||||
<Button disabled={loading} variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button disabled={loading} variant="destructive" onClick={onConfirm}>
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
113
apps/web/components/nav-main.tsx
Normal file
113
apps/web/components/nav-main.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@repo/shadcn/collapsible';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@repo/shadcn/sidebar';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Icons } from './icons';
|
||||
// import { useSession } from 'next-auth/react';
|
||||
|
||||
export function NavMain({
|
||||
titleGroup,
|
||||
items,
|
||||
role
|
||||
}: {
|
||||
titleGroup: string,
|
||||
role: string,
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: keyof typeof Icons;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: keyof typeof Icons;
|
||||
role?: string[];
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
// const { data: session } = useSession();
|
||||
|
||||
// const userRole = session?.user.role[0]?.rol ? session.user.role[0].rol :'';
|
||||
|
||||
// console.log(session?.user.role[0]?.rol);
|
||||
return (
|
||||
<SidebarGroup>
|
||||
|
||||
<SidebarGroupLabel>{titleGroup}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||
return item?.items && item?.items?.length > 0 ? (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
{item.icon && <Icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
subItem.role?.includes(role) &&
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === subItem.url}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
111
apps/web/components/nav-projects.tsx
Normal file
111
apps/web/components/nav-projects.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Folder,
|
||||
Forward,
|
||||
Frame,
|
||||
PanelLeft,
|
||||
PieChart,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@repo/shadcn/dropdown-menu';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@repo/shadcn/sidebar';
|
||||
|
||||
const data = {
|
||||
projects: [
|
||||
{
|
||||
name: 'Design Engineering',
|
||||
url: '#',
|
||||
icon: Frame,
|
||||
},
|
||||
{
|
||||
name: 'Sales & Marketing',
|
||||
url: '#',
|
||||
icon: PieChart,
|
||||
},
|
||||
{
|
||||
name: 'Travel',
|
||||
url: '#',
|
||||
icon: Map,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function NavProjects({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align={isMobile ? 'end' : 'start'}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder className="text-muted-foreground" />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward className="text-muted-foreground" />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<PanelLeft className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
117
apps/web/components/team-switcher.tsx
Normal file
117
apps/web/components/team-switcher.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AudioWaveform,
|
||||
ChevronsUpDownIcon,
|
||||
Command,
|
||||
GalleryVerticalEnd,
|
||||
PlusIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@repo/shadcn/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@repo/shadcn/sidebar';
|
||||
import * as React from 'react';
|
||||
|
||||
const data = {
|
||||
teams: [
|
||||
{
|
||||
name: 'Acme Inc',
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: 'Enterprise',
|
||||
},
|
||||
{
|
||||
name: 'Acme Corp.',
|
||||
logo: AudioWaveform,
|
||||
plan: 'Startup',
|
||||
},
|
||||
{
|
||||
name: 'Evil Corp.',
|
||||
logo: Command,
|
||||
plan: 'Free',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function TeamSwitcher({
|
||||
teams,
|
||||
}: {
|
||||
teams: {
|
||||
name: string;
|
||||
logo: React.ElementType;
|
||||
plan: string;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
||||
|
||||
if (!activeTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<activeTeam.logo className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeTeam.name}</span>
|
||||
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
<DropdownMenuItem
|
||||
key={team.name}
|
||||
onClick={() => setActiveTeam(team)}
|
||||
className="gap-2 p-2"
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-xs border">
|
||||
<team.logo className="size-4 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<div className="bg-background flex size-6 items-center justify-center rounded-md border">
|
||||
<PlusIcon className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground font-medium">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
74
apps/web/constants/data.ts
Normal file
74
apps/web/constants/data.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NavItem } from '@/types';
|
||||
|
||||
//Info: The following data is used for the sidebar navigation and Cmd K bar.
|
||||
export const GeneralItems: NavItem[] = [
|
||||
{
|
||||
title: 'Encuestas',
|
||||
url: '/dashboard/encuestas/',
|
||||
icon: 'notepadText',
|
||||
shortcut: ['p', 'p'],
|
||||
isActive: false,
|
||||
items: [], // No child items
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
export const AdministrationItems: NavItem[] = [
|
||||
{
|
||||
title: 'Administracion',
|
||||
url: '#', // Placeholder as there is no direct link for the parent
|
||||
icon: 'settings2',
|
||||
isActive: true,
|
||||
role:['admin','superadmin','manager','user'], // sumatoria de los roles que si tienen acceso
|
||||
|
||||
items: [
|
||||
{
|
||||
title: 'Usuarios',
|
||||
url: '/dashboard/administracion/usuario',
|
||||
icon: 'userPen',
|
||||
shortcut: ['m', 'm'],
|
||||
role:['admin','superadmin'],
|
||||
},
|
||||
{
|
||||
title: 'Encuestas',
|
||||
shortcut: ['l', 'l'],
|
||||
url: '/dashboard/administracion/encuestas',
|
||||
icon: 'login',
|
||||
role:['admin','superadmin','manager','user'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const StatisticsItems: NavItem[] = [
|
||||
{
|
||||
title: 'Estadísticas',
|
||||
url: '#', // Placeholder as there is no direct link for the parent
|
||||
icon: 'chartColumn',
|
||||
isActive: true,
|
||||
role:['admin','superadmin','autoridad'],
|
||||
|
||||
items: [
|
||||
// {
|
||||
// title: 'Usuarios',
|
||||
// url: '/dashboard/estadisticas/usuarios',
|
||||
// icon: 'userPen',
|
||||
// shortcut: ['m', 'm'],
|
||||
// role:['admin','superadmin','autoridad'],
|
||||
// },
|
||||
{
|
||||
title: 'Encuestas',
|
||||
shortcut: ['l', 'l'],
|
||||
url: '/dashboard/estadisticas/encuestas',
|
||||
icon: 'notepadText',
|
||||
role:['admin','superadmin','autoridad'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
apps/web/eslint.config.js
Normal file
4
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { nextJsConfig } from '@repo/eslint-config/next-js';
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default nextJsConfig;
|
||||
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())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user