base con autenticacion, registro, modulo encuestas

This commit is contained in:
2025-06-16 12:02:22 -04:00
commit 475e0754df
411 changed files with 26265 additions and 0 deletions

4
apps/web/.env_template Normal file
View 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
View 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
View 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;
};
```

View 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;

View File

@@ -0,0 +1,2 @@
import { handlers } from '@/lib/auth'; // Referring to the auth.ts we just created
export const { GET, POST } = handlers;

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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');
}
}

View 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
View 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;

Binary file not shown.

Binary file not shown.

64
apps/web/app/layout.tsx Normal file
View 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;

View 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

Binary file not shown.

62
apps/web/app/og/route.tsx Normal file
View 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',
},
],
},
);
}

View 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,
},
);
}

View 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
View 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"
}
}

View 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>
);
}

View 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
};

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

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

View 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;

View 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>
);
}
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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'],
},
],
},
];

View File

@@ -0,0 +1,4 @@
import { nextJsConfig } from '@repo/eslint-config/next-js';
/** @type {import("eslint").Linter.Config} */
export default nextJsConfig;

View 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;
}
};

View 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;
}
};

View 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;
};

View 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>
)
}

View 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>
)
}

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

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

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

View 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>;

View 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,
});

View 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,
})

View 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;
};

View 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))
}

View 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),
})

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View 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>;

View 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;
}

View 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;
}

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
'use client';
import { DataTableFilterBox } from '@repo/shadcn/table/data-table-filter-box';
import { DataTableSearch } from '@repo/shadcn/table/data-table-search';
import {
TYPE_OPTIONS,
useSurveyTableFilters,
} from './use-survey-table-filters';
export default function 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>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}

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

View 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;
}

View 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))
}

View 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>;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@repo/shadcn/dialog';
import { AccountPlan } from '@/feactures/users/schemas/account-plan.schema';
import { 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>
);
}

View File

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

View File

@@ -0,0 +1,27 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@repo/shadcn/button';
import { Heading } from '@repo/shadcn/heading';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AccountPlanModal } from './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} />
</>
);
}

View 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>
);
}

View 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>
)}
/>
}

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

View 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>
);
}

View 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>
);
}

View 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)
})
}

View 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