mejoras al formulario de registro organizaciones productivas

This commit is contained in:
2026-01-22 14:28:24 -04:00
parent 69b3aab02a
commit 08a5567d60
34 changed files with 4297 additions and 1102 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
'use client';
import { Heading } from '@repo/shadcn/heading';
export function TrainingHeader() {
return (
<div className="flex items-start justify-between mb-2">
<Heading
title="Registro de Organizaciones Socioproductivas"
description="Gestiona los registros de las OSP"
/>
</div>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { DataTable } from '@repo/shadcn/table/data-table';
import { DataTableSkeleton } from '@repo/shadcn/table/data-table-skeleton';
import { useTrainingQuery } from '../hooks/use-training';
import { columns } from './training-tables/columns';
interface TrainingListProps {
initialPage: number;
initialSearch?: string | null;
initialLimit: number;
}
export default function TrainingList({
initialPage,
initialSearch,
initialLimit,
}: TrainingListProps) {
const filters = {
page: initialPage,
limit: initialLimit,
...(initialSearch && { search: initialSearch }),
};
const { data, isLoading } = useTrainingQuery(filters);
if (isLoading) {
return <DataTableSkeleton columnCount={5} 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,113 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { useDeleteTraining } from '@/feactures/training/hooks/use-training';
import { TrainingSchema } from '@/feactures/training/schemas/training';
import { Button } from '@repo/shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@repo/shadcn/tooltip';
import { Edit, Eye, Trash } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { TrainingViewModal } from '../training-view-modal';
interface CellActionProps {
data: TrainingSchema;
}
export const CellAction: React.FC<CellActionProps> = ({ data }) => {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [viewOpen, setViewOpen] = useState(false);
const { mutate: deleteTraining } = useDeleteTraining();
const router = useRouter();
const onConfirm = async () => {
try {
setLoading(true);
deleteTraining(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 este registro?"
description="Esta acción no se puede deshacer."
/>
<TrainingViewModal
isOpen={viewOpen}
onClose={() => setViewOpen(false)}
data={data}
/>
<div className="flex gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setViewOpen(true)}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ver detalle</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
router.push(`/dashboard/formulario/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,45 @@
'use client';
import { TrainingSchema } from '@/feactures/training/schemas/training';
import { Badge } from '@repo/shadcn/badge';
import { ColumnDef } from '@tanstack/react-table';
import { CellAction } from './cell-action';
export const columns: ColumnDef<TrainingSchema>[] = [
{
accessorKey: 'ospName',
header: 'Nombre OSP',
},
{
accessorKey: 'ospRif',
header: 'RIF',
},
{
accessorKey: 'ospType',
header: 'Tipo',
},
{
accessorKey: 'currentStatus',
header: 'Estatus',
cell: ({ row }) => {
const status = row.getValue('currentStatus') as string;
return (
<Badge variant={status === 'ACTIVA' ? 'default' : 'secondary'}>
{status}
</Badge>
);
},
},
{
accessorKey: 'visitDate',
header: 'Fecha Visita',
cell: ({ row }) => {
const date = row.getValue('visitDate') as string;
return date ? new Date(date).toLocaleString() : 'N/A';
},
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => <CellAction data={row.original} />,
},
];

View File

@@ -0,0 +1,31 @@
'use client';
import { Button } from '@repo/shadcn/components/ui/button';
import { DataTableSearch } from '@repo/shadcn/table/data-table-search';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTrainingTableFilters } from './use-training-table-filters';
export default function TrainingTableAction() {
const { searchQuery, setPage, setSearchQuery } = useTrainingTableFilters();
const router = useRouter();
return (
<div className="flex items-center justify-between mt-4 ">
<div className="flex items-center gap-4 flex-grow">
<DataTableSearch
searchKey="nombre"
searchQuery={searchQuery || ''}
setSearchQuery={setSearchQuery}
setPage={setPage}
/>
</div>{' '}
<Button
onClick={() => router.push(`/dashboard/formulario/nuevo`)}
size="sm"
>
<Plus className="h-4 w-4" />
Nuevo Registro
</Button>
</div>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { searchParams } from '@repo/shadcn/lib/searchparams';
import { useQueryState } from 'nuqs';
import { useCallback, useMemo } from 'react';
export function useTrainingTableFilters() {
const [searchQuery, setSearchQuery] = useQueryState(
'q',
searchParams.q
.withOptions({
shallow: false,
throttleMs: 500,
})
.withDefault(''),
);
const [page, setPage] = useQueryState(
'page',
searchParams.page.withDefault(1),
);
const resetFilters = useCallback(() => {
setSearchQuery(null);
setPage(1);
}, [setSearchQuery, setPage]);
const isAnyFilterActive = useMemo(() => {
return !!searchQuery;
}, [searchQuery]);
return {
searchQuery,
setSearchQuery,
page,
setPage,
resetFilters,
isAnyFilterActive,
};
}

View File

@@ -0,0 +1,285 @@
'use client';
import { Badge } from '@repo/shadcn/badge';
import { Button } from '@repo/shadcn/button';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@repo/shadcn/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@repo/shadcn/components/ui/dialog';
import { X } from 'lucide-react';
import React, { useState } from 'react';
import { TrainingSchema } from '../schemas/training';
interface TrainingViewModalProps {
data: TrainingSchema | null;
isOpen: boolean;
onClose: () => void;
}
export function TrainingViewModal({
data,
isOpen,
onClose,
}: TrainingViewModalProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
if (!data) return null;
const DetailItem = ({ label, value }: { label: string; value: any }) => (
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="text-sm font-semibold">{value || 'N/A'}</p>
</div>
);
const Section = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<Card className="mb-4">
<CardHeader className="py-3">
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{children}
</CardContent>
</Card>
);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[800px] overflow-y-auto [&>button:last-child]:hidden">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Detalle de la Organización Socioproductiva
</DialogTitle>
<DialogDescription className="sr-only">
Resumen detallado de la información de la organización
socioproductiva incluyendo ubicación, responsable y registro
fotográfico.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-6">
{/* 1. Datos de la visita */}
<Section title="1. Datos de la visita">
<DetailItem
label="Nombre del Coordinador"
value={data.firstname}
/>
<DetailItem
label="Apellido del Coordinador"
value={data.lastname}
/>
<DetailItem
label="Fecha y hora de la visita"
value={
data.visitDate
? new Date(data.visitDate).toLocaleString()
: 'N/A'
}
/>
</Section>
{/* 2. Datos de la OSP */}
<Section title="2. Datos de la OSP">
<DetailItem label="Nombre" value={data.ospName} />
<DetailItem label="RIF" value={data.ospRif} />
<DetailItem label="Tipo" value={data.ospType} />
<DetailItem
label="Actividad Productiva"
value={data.productiveActivity}
/>
<DetailItem
label="Estatus"
value={
<Badge
variant={
data.currentStatus === 'ACTIVA' ? 'default' : 'secondary'
}
>
{data.currentStatus}
</Badge>
}
/>
<DetailItem
label="Año de Constitución"
value={data.companyConstitutionYear}
/>
<DetailItem
label="Cant. Productores"
value={data.producerCount}
/>
<DetailItem label="Cant. Productos" value={data.productCount} />
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem
label="Descripción del Producto"
value={data.productDescription}
/>
</div>
<DetailItem
label="Capacidad Instalada"
value={data.installedCapacity}
/>
<DetailItem
label="Capacidad Operativa"
value={data.operationalCapacity}
/>
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem
label="Requerimiento Financiero"
value={data.financialRequirementDescription}
/>
</div>
{data.paralysisReason && (
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem
label="Razones de paralización"
value={data.paralysisReason}
/>
</div>
)}
</Section>
{/* 3. Ubicación */}
<Section title="3. Detalles de la ubicación">
<DetailItem
label="Código SITUR Comuna"
value={data.siturCodeCommune}
/>
<DetailItem
label="Consejo Comunal"
value={data.communalCouncil}
/>
<DetailItem
label="Código SITUR Consejo Comunal"
value={data.siturCodeCommunalCouncil}
/>
<div className="col-span-1 md:col-span-2 lg:col-span-3">
<DetailItem label="Dirección OSP" value={data.ospAddress} />
</div>
</Section>
{/* 4. Responsable */}
<Section title="4. Datos del Responsable">
<DetailItem
label="Nombre Completo"
value={data.ospResponsibleFullname}
/>
<DetailItem label="Cédula" value={data.ospResponsibleCedula} />
<DetailItem label="RIF" value={data.ospResponsibleRif} />
<DetailItem label="Estado Civil" value={data.civilState} />
<DetailItem label="Teléfono" value={data.ospResponsiblePhone} />
<DetailItem label="Email" value={data.ospResponsibleEmail} />
<DetailItem label="Carga Familiar" value={data.familyBurden} />
<DetailItem
label="Número de Hijos"
value={data.numberOfChildren}
/>
</Section>
{/* 5. Observaciones */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-lg">
5. Observaciones Generales
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{data.generalObservations || 'Sin observaciones'}
</p>
</CardContent>
</Card>
{/* 6. Registro fotográfico */}
<Card>
<CardHeader className="py-3">
<CardTitle className="text-lg">
6. Registro fotográfico
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{[data.photo1, data.photo2, data.photo3].map(
(photo, idx) =>
photo && (
<div
key={idx}
className="relative aspect-video rounded-md overflow-hidden cursor-pointer hover:opacity-90 transition-opacity bg-muted"
onClick={() => setSelectedImage(photo)}
>
<img
src={`${process.env.NEXT_PUBLIC_API_URL}${photo}`}
alt={`Registro ${idx + 1}`}
className="object-cover w-full h-full"
/>
</div>
),
)}
{![data.photo1, data.photo2, data.photo3].some(Boolean) && (
<p className="text-sm text-muted-foreground">
No hay registro fotográfico
</p>
)}
</div>
</CardContent>
</Card>
</div>
<DialogFooter className="mt-6">
<Button onClick={onClose} variant="outline" className="w-32">
Cerrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Lightbox */}
<Dialog
open={!!selectedImage}
onOpenChange={() => setSelectedImage(null)}
>
<DialogContent className="max-w-[95vw] max-h-[95vh] p-0 overflow-hidden bg-black/90 border-none [&>button:last-child]:hidden">
<DialogHeader className="sr-only">
<DialogTitle>Vista ampliada de la imagen</DialogTitle>
<DialogDescription>
Imagen ampliada del registro fotográfico de la organización.
</DialogDescription>
</DialogHeader>
<div className="relative w-full h-full flex items-center justify-center p-4">
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 text-white hover:bg-white/20 z-10"
onClick={() => setSelectedImage(null)}
>
<X className="h-6 w-6" />
</Button>
{selectedImage && (
<img
src={`${process.env.NEXT_PUBLIC_API_URL}${selectedImage}`}
alt="Expanded view"
className="max-w-full max-h-[90vh] object-contain"
/>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}