ADM-001: Medios y Secciones (fundacional) #15
@@ -12,6 +12,8 @@ import {
|
|||||||
FileClock,
|
FileClock,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
|
Newspaper,
|
||||||
|
Columns3,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -47,6 +49,18 @@ const adminItems: NavItem[] = [
|
|||||||
icon: FileClock,
|
icon: FileClock,
|
||||||
requiredPermission: 'administracion:auditoria:ver',
|
requiredPermission: 'administracion:auditoria:ver',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Medios',
|
||||||
|
href: '/admin/medios',
|
||||||
|
icon: Newspaper,
|
||||||
|
requiredPermission: 'administracion:medios:gestionar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Secciones',
|
||||||
|
href: '/admin/secciones',
|
||||||
|
icon: Columns3,
|
||||||
|
requiredPermission: 'administracion:secciones:gestionar',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
|
|||||||
7
src/web/src/features/medios/api/createMedio.ts
Normal file
7
src/web/src/features/medios/api/createMedio.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateMedioRequest, MedioCreated } from '../types'
|
||||||
|
|
||||||
|
export async function createMedio(payload: CreateMedioRequest): Promise<MedioCreated> {
|
||||||
|
const response = await axiosClient.post<MedioCreated>('/api/v1/admin/medios', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/medios/api/deactivateMedio.ts
Normal file
5
src/web/src/features/medios/api/deactivateMedio.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateMedio(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/medios/${id}/deactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/medios/api/getMedio.ts
Normal file
7
src/web/src/features/medios/api/getMedio.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { MedioDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getMedio(id: number): Promise<MedioDetail> {
|
||||||
|
const response = await axiosClient.get<MedioDetail>(`/api/v1/admin/medios/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
17
src/web/src/features/medios/api/listMedios.ts
Normal file
17
src/web/src/features/medios/api/listMedios.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { MedioListItem, MediosQuery, PagedResult } from '../types'
|
||||||
|
|
||||||
|
export async function listMedios(query: MediosQuery): Promise<PagedResult<MedioListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||||
|
if (query.tipo !== undefined) params.set('tipo', String(query.tipo))
|
||||||
|
if (query.q !== undefined && query.q !== '') params.set('q', query.q)
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<MedioListItem>>(
|
||||||
|
'/api/v1/admin/medios',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/medios/api/reactivateMedio.ts
Normal file
5
src/web/src/features/medios/api/reactivateMedio.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function reactivateMedio(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/medios/${id}/reactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/medios/api/updateMedio.ts
Normal file
7
src/web/src/features/medios/api/updateMedio.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { MedioDetail, UpdateMedioRequest } from '../types'
|
||||||
|
|
||||||
|
export async function updateMedio(id: number, payload: UpdateMedioRequest): Promise<MedioDetail> {
|
||||||
|
const response = await axiosClient.put<MedioDetail>(`/api/v1/admin/medios/${id}`, payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDeactivateMedio } from '../hooks/useDeactivateMedio'
|
||||||
|
import { useReactivateMedio } from '../hooks/useReactivateMedio'
|
||||||
|
|
||||||
|
interface DeactivateMedioModalProps {
|
||||||
|
medioId: number
|
||||||
|
medioNombre: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeactivateMedioModal({ medioId, medioNombre, activo }: DeactivateMedioModalProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { mutate: deactivate, isPending: deactivating } = useDeactivateMedio()
|
||||||
|
const { mutate: reactivate, isPending: reactivating } = useReactivateMedio()
|
||||||
|
|
||||||
|
const isPending = deactivating || reactivating
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (activo) {
|
||||||
|
deactivate(medioId, { onSuccess: () => setOpen(false) })
|
||||||
|
} else {
|
||||||
|
reactivate(medioId, { onSuccess: () => setOpen(false) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{activo ? 'Desactivar medio' : 'Reactivar medio'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{activo
|
||||||
|
? `¿Confirmás que querés desactivar el medio "${medioNombre}"? El medio no podrá usarse hasta que sea reactivado.`
|
||||||
|
: `¿Confirmás que querés reactivar el medio "${medioNombre}"?`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
src/web/src/features/medios/components/MedioForm.tsx
Normal file
188
src/web/src/features/medios/components/MedioForm.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { TIPO_MEDIO_OPTIONS } from '../tipoMedio'
|
||||||
|
import type { MedioDetail } from '../types'
|
||||||
|
|
||||||
|
const medioFormSchema = z.object({
|
||||||
|
codigo: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El código es requerido')
|
||||||
|
.max(20, 'Máximo 20 caracteres'),
|
||||||
|
nombre: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(100, 'Máximo 100 caracteres'),
|
||||||
|
tipo: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un tipo válido'),
|
||||||
|
plataformaEmpresaId: z
|
||||||
|
.union([z.coerce.number().int().positive('Debe ser un número positivo'), z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === '' || v === undefined ? null : Number(v))),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type MedioFormValues = z.infer<typeof medioFormSchema>
|
||||||
|
|
||||||
|
interface MedioFormProps {
|
||||||
|
/** If provided, the form is in edit mode (código is disabled, form is pre-filled). */
|
||||||
|
initialData?: MedioDetail
|
||||||
|
isPending: boolean
|
||||||
|
error: unknown
|
||||||
|
onSubmit: (values: MedioFormValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'medio_codigo_duplicado') {
|
||||||
|
return data.message ?? 'Ya existe un medio con ese código'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar el medio'
|
||||||
|
}
|
||||||
|
return 'Error al guardar el medio'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedioForm({ initialData, isPending, error, onSubmit }: MedioFormProps) {
|
||||||
|
const isEdit = !!initialData
|
||||||
|
|
||||||
|
const form = useForm<MedioFormValues>({
|
||||||
|
resolver: zodResolver(medioFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
codigo: initialData?.codigo ?? '',
|
||||||
|
nombre: initialData?.nombre ?? '',
|
||||||
|
tipo: initialData?.tipo ?? ('' as unknown as number),
|
||||||
|
plataformaEmpresaId: (initialData?.plataformaEmpresaId ?? '') as unknown as undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset({
|
||||||
|
codigo: initialData.codigo,
|
||||||
|
nombre: initialData.nombre,
|
||||||
|
tipo: initialData.tipo,
|
||||||
|
plataformaEmpresaId: (initialData.plataformaEmpresaId ?? '') as unknown as undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="Ej: DIARIO01"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Nombre del medio"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tipo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tipo"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un tipo</option>
|
||||||
|
{TIPO_MEDIO_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="plataformaEmpresaId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Plataforma Empresa ID (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="ID numérico (opcional)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear medio'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/web/src/features/medios/components/MediosTable.tsx
Normal file
103
src/web/src/features/medios/components/MediosTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import type { MedioListItem } from '../types'
|
||||||
|
import { tipoMedioLabel } from '../tipoMedio'
|
||||||
|
import { DeactivateMedioModal } from './DeactivateMedioModal'
|
||||||
|
|
||||||
|
interface MediosTableProps {
|
||||||
|
rows: MedioListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediosTable({ rows }: MediosTableProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<MedioListItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'codigo',
|
||||||
|
header: 'Código',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.codigo}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'nombre',
|
||||||
|
header: 'Nombre',
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'tipo',
|
||||||
|
header: 'Tipo',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary">{tipoMedioLabel(row.original.tipo)}</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'plataformaEmpresaId',
|
||||||
|
header: 'Plataforma ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{row.original.plataformaEmpresaId ?? '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'activo',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.activo ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CanPerform permission="administracion:medios:gestionar">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/medios/${row.original.id}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateMedioModal
|
||||||
|
medioId={row.original.id}
|
||||||
|
medioNombre={row.original.nombre}
|
||||||
|
activo={row.original.activo}
|
||||||
|
/>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => navigate(`/admin/medios/${row.id}`)}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
emptyMessage="Sin resultados — no se encontraron medios con los filtros seleccionados."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/medios/hooks/useCreateMedio.ts
Normal file
13
src/web/src/features/medios/hooks/useCreateMedio.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createMedio } from '../api/createMedio'
|
||||||
|
import type { CreateMedioRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateMedio() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateMedioRequest) => createMedio(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/medios/hooks/useDeactivateMedio.ts
Normal file
12
src/web/src/features/medios/hooks/useDeactivateMedio.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateMedio } from '../api/deactivateMedio'
|
||||||
|
|
||||||
|
export function useDeactivateMedio() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateMedio(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/web/src/features/medios/hooks/useMedio.ts
Normal file
11
src/web/src/features/medios/hooks/useMedio.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getMedio } from '../api/getMedio'
|
||||||
|
|
||||||
|
export function useMedio(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['medios', 'detail', id],
|
||||||
|
queryFn: () => getMedio(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/medios/hooks/useMediosList.ts
Normal file
13
src/web/src/features/medios/hooks/useMediosList.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listMedios } from '../api/listMedios'
|
||||||
|
import type { MediosQuery } from '../types'
|
||||||
|
|
||||||
|
export const mediosListQueryKey = (query: MediosQuery) => ['medios', 'list', query] as const
|
||||||
|
|
||||||
|
export function useMediosList(query: MediosQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: mediosListQueryKey(query),
|
||||||
|
queryFn: () => listMedios(query),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/medios/hooks/useReactivateMedio.ts
Normal file
12
src/web/src/features/medios/hooks/useReactivateMedio.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reactivateMedio } from '../api/reactivateMedio'
|
||||||
|
|
||||||
|
export function useReactivateMedio() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateMedio(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/medios/hooks/useUpdateMedio.ts
Normal file
13
src/web/src/features/medios/hooks/useUpdateMedio.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateMedio } from '../api/updateMedio'
|
||||||
|
import type { UpdateMedioRequest } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateMedio(id: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateMedioRequest) => updateMedio(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
50
src/web/src/features/medios/pages/CreateMedioPage.tsx
Normal file
50
src/web/src/features/medios/pages/CreateMedioPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { MedioForm } from '../components/MedioForm'
|
||||||
|
import { useCreateMedio } from '../hooks/useCreateMedio'
|
||||||
|
import type { MedioFormValues } from '../components/MedioForm'
|
||||||
|
|
||||||
|
export function CreateMedioPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { mutate, isPending, error } = useCreateMedio()
|
||||||
|
|
||||||
|
function handleSubmit(values: MedioFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
codigo: values.codigo,
|
||||||
|
nombre: values.nombre,
|
||||||
|
tipo: values.tipo,
|
||||||
|
plataformaEmpresaId: values.plataformaEmpresaId as number | null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Medio creado correctamente')
|
||||||
|
void navigate('/admin/medios')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Crear Medio</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Completá los datos para registrar un nuevo medio en el sistema.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MedioForm isPending={isPending} error={error} onSubmit={handleSubmit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/web/src/features/medios/pages/EditMedioPage.tsx
Normal file
81
src/web/src/features/medios/pages/EditMedioPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { MedioForm } from '../components/MedioForm'
|
||||||
|
import { useMedio } from '../hooks/useMedio'
|
||||||
|
import { useUpdateMedio } from '../hooks/useUpdateMedio'
|
||||||
|
import type { MedioFormValues } from '../components/MedioForm'
|
||||||
|
|
||||||
|
export function EditMedioPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const medioId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: medio, isLoading } = useMedio(medioId)
|
||||||
|
const { mutate, isPending, error } = useUpdateMedio(medioId)
|
||||||
|
|
||||||
|
function handleSubmit(values: MedioFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
nombre: values.nombre,
|
||||||
|
tipo: values.tipo,
|
||||||
|
plataformaEmpresaId: values.plataformaEmpresaId as number | null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Medio actualizado correctamente')
|
||||||
|
void navigate(`/admin/medios/${medioId}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!medio) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Medio no encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl">Editar Medio</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/medios')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Editá los datos del medio <strong>{medio.nombre}</strong>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MedioForm
|
||||||
|
initialData={medio}
|
||||||
|
isPending={isPending}
|
||||||
|
error={error}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/web/src/features/medios/pages/MedioDetailPage.tsx
Normal file
97
src/web/src/features/medios/pages/MedioDetailPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useMedio } from '../hooks/useMedio'
|
||||||
|
import { DeactivateMedioModal } from '../components/DeactivateMedioModal'
|
||||||
|
import { tipoMedioLabel } from '../tipoMedio'
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('es-AR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedioDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const medioId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: medio, isLoading } = useMedio(medioId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!medio) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Medio no encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">{medio.nombre}</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/medios')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-4 space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Código</span>
|
||||||
|
<span className="font-mono">{medio.codigo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tipo</span>
|
||||||
|
<Badge variant="secondary">{tipoMedioLabel(medio.tipo)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Plataforma ID</span>
|
||||||
|
<span>{medio.plataformaEmpresaId ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Estado</span>
|
||||||
|
{medio.activo
|
||||||
|
? <Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Activo</Badge>
|
||||||
|
: <Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Inactivo</Badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Creado</span>
|
||||||
|
<span>{formatDate(medio.fechaCreacion)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Modificado</span>
|
||||||
|
<span>{formatDate(medio.fechaModificacion)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CanPerform permission="administracion:medios:gestionar">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/admin/medios/${medioId}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateMedioModal
|
||||||
|
medioId={medioId}
|
||||||
|
medioNombre={medio.nombre}
|
||||||
|
activo={medio.activo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
src/web/src/features/medios/pages/MediosListPage.tsx
Normal file
138
src/web/src/features/medios/pages/MediosListPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
import { MediosTable } from '../components/MediosTable'
|
||||||
|
import { useMediosList } from '../hooks/useMediosList'
|
||||||
|
import { TIPO_MEDIO_OPTIONS } from '../tipoMedio'
|
||||||
|
|
||||||
|
export function MediosListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [tipo, setTipo] = useState<number | undefined>(undefined)
|
||||||
|
const [activo, setActivo] = useState<boolean | undefined>(undefined)
|
||||||
|
const [searchRaw, setSearchRaw] = useState('')
|
||||||
|
const q = useDebouncedValue(searchRaw, 300)
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(tipo !== undefined ? { tipo } : {}),
|
||||||
|
...(activo !== undefined ? { activo } : {}),
|
||||||
|
...(q ? { q } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useMediosList(query)
|
||||||
|
|
||||||
|
const handleTipoChange = useCallback((value: string) => {
|
||||||
|
setTipo(value === '' ? undefined : Number(value))
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleActivoChange = useCallback((value: string) => {
|
||||||
|
if (value === '') setActivo(undefined)
|
||||||
|
else setActivo(value === 'true')
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchRaw(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Medios</h1>
|
||||||
|
<CanPerform permission="administracion:medios:gestionar">
|
||||||
|
<Button onClick={() => navigate('/admin/medios/nuevo')} size="sm">
|
||||||
|
Nuevo medio
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por código, nombre..."
|
||||||
|
value={searchRaw}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Buscar medios"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Tipo"
|
||||||
|
onChange={(e) => handleTipoChange(e.target.value)}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{TIPO_MEDIO_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Estado"
|
||||||
|
onChange={(e) => handleActivoChange(e.target.value)}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MediosTable rows={data?.items ?? []} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} medio${data.total !== 1 ? 's' : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/web/src/features/medios/tipoMedio.ts
Normal file
18
src/web/src/features/medios/tipoMedio.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// TipoMedio enum helper: int on wire → display label
|
||||||
|
export const TIPO_MEDIO_LABELS: Record<number, string> = {
|
||||||
|
1: 'Diario',
|
||||||
|
2: 'Radio',
|
||||||
|
3: 'Web',
|
||||||
|
4: 'Poster',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIPO_MEDIO_OPTIONS = [
|
||||||
|
{ value: 1, label: 'Diario' },
|
||||||
|
{ value: 2, label: 'Radio' },
|
||||||
|
{ value: 3, label: 'Web' },
|
||||||
|
{ value: 4, label: 'Poster' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function tipoMedioLabel(tipo: number): string {
|
||||||
|
return TIPO_MEDIO_LABELS[tipo] ?? String(tipo)
|
||||||
|
}
|
||||||
58
src/web/src/features/medios/types.ts
Normal file
58
src/web/src/features/medios/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// ADM-001 — shared types for medios feature
|
||||||
|
|
||||||
|
export interface MedioListItem {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId: number | null
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedioDetail {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId: number | null
|
||||||
|
activo: boolean
|
||||||
|
fechaCreacion: string
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedioCreated {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId: number | null
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMedioRequest {
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMedioRequest {
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediosQuery {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
activo?: boolean
|
||||||
|
tipo?: number
|
||||||
|
q?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
7
src/web/src/features/secciones/api/createSeccion.ts
Normal file
7
src/web/src/features/secciones/api/createSeccion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateSeccionRequest, SeccionCreated } from '../types'
|
||||||
|
|
||||||
|
export async function createSeccion(payload: CreateSeccionRequest): Promise<SeccionCreated> {
|
||||||
|
const response = await axiosClient.post<SeccionCreated>('/api/v1/admin/secciones', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/secciones/api/deactivateSeccion.ts
Normal file
5
src/web/src/features/secciones/api/deactivateSeccion.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateSeccion(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/secciones/${id}/deactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/secciones/api/getSeccion.ts
Normal file
7
src/web/src/features/secciones/api/getSeccion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SeccionDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getSeccion(id: number): Promise<SeccionDetail> {
|
||||||
|
const response = await axiosClient.get<SeccionDetail>(`/api/v1/admin/secciones/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
18
src/web/src/features/secciones/api/listSecciones.ts
Normal file
18
src/web/src/features/secciones/api/listSecciones.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SeccionListItem, SeccionesQuery, PagedResult } from '../types'
|
||||||
|
|
||||||
|
export async function listSecciones(query: SeccionesQuery): Promise<PagedResult<SeccionListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
if (query.medioId !== undefined) params.set('medioId', String(query.medioId))
|
||||||
|
if (query.tipo !== undefined) params.set('tipo', query.tipo)
|
||||||
|
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||||
|
if (query.q !== undefined && query.q !== '') params.set('q', query.q)
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<SeccionListItem>>(
|
||||||
|
'/api/v1/admin/secciones',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/secciones/api/reactivateSeccion.ts
Normal file
5
src/web/src/features/secciones/api/reactivateSeccion.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function reactivateSeccion(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/secciones/${id}/reactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/secciones/api/updateSeccion.ts
Normal file
7
src/web/src/features/secciones/api/updateSeccion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SeccionDetail, UpdateSeccionRequest } from '../types'
|
||||||
|
|
||||||
|
export async function updateSeccion(id: number, payload: UpdateSeccionRequest): Promise<SeccionDetail> {
|
||||||
|
const response = await axiosClient.put<SeccionDetail>(`/api/v1/admin/secciones/${id}`, payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDeactivateSeccion } from '../hooks/useDeactivateSeccion'
|
||||||
|
import { useReactivateSeccion } from '../hooks/useReactivateSeccion'
|
||||||
|
|
||||||
|
interface DeactivateSeccionModalProps {
|
||||||
|
seccionId: number
|
||||||
|
seccionNombre: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeactivateSeccionModal({ seccionId, seccionNombre, activo }: DeactivateSeccionModalProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { mutate: deactivate, isPending: deactivating } = useDeactivateSeccion()
|
||||||
|
const { mutate: reactivate, isPending: reactivating } = useReactivateSeccion()
|
||||||
|
|
||||||
|
const isPending = deactivating || reactivating
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (activo) {
|
||||||
|
deactivate(seccionId, { onSuccess: () => setOpen(false) })
|
||||||
|
} else {
|
||||||
|
reactivate(seccionId, { onSuccess: () => setOpen(false) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{activo ? 'Desactivar sección' : 'Reactivar sección'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{activo
|
||||||
|
? `¿Confirmás que querés desactivar la sección "${seccionNombre}"?`
|
||||||
|
: `¿Confirmás que querés reactivar la sección "${seccionNombre}"?`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/web/src/features/secciones/components/SeccionForm.tsx
Normal file
195
src/web/src/features/secciones/components/SeccionForm.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { useMediosList } from '@/features/medios/hooks/useMediosList'
|
||||||
|
import { TIPO_SECCION_OPTIONS } from '../tipoSeccion'
|
||||||
|
import type { SeccionDetail, TipoSeccion } from '../types'
|
||||||
|
|
||||||
|
const TIPO_SECCION_VALUES = ['clasificados', 'notables', 'suplementos'] as const
|
||||||
|
|
||||||
|
const seccionFormSchema = z.object({
|
||||||
|
medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'),
|
||||||
|
codigo: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El código es requerido')
|
||||||
|
.max(20, 'Máximo 20 caracteres'),
|
||||||
|
nombre: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(100, 'Máximo 100 caracteres'),
|
||||||
|
tipo: z.enum(TIPO_SECCION_VALUES, { errorMap: () => ({ message: 'Seleccioná un tipo válido' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SeccionFormValues = z.infer<typeof seccionFormSchema>
|
||||||
|
|
||||||
|
interface SeccionFormProps {
|
||||||
|
initialData?: SeccionDetail
|
||||||
|
isPending: boolean
|
||||||
|
error: unknown
|
||||||
|
onSubmit: (values: SeccionFormValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'seccion_codigo_duplicado_en_medio') {
|
||||||
|
return data.message ?? 'Ya existe una sección con ese código en el medio'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar la sección'
|
||||||
|
}
|
||||||
|
return 'Error al guardar la sección'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionForm({ initialData, isPending, error, onSubmit }: SeccionFormProps) {
|
||||||
|
const isEdit = !!initialData
|
||||||
|
|
||||||
|
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 })
|
||||||
|
const medios = mediosData?.items ?? []
|
||||||
|
|
||||||
|
const form = useForm<SeccionFormValues>({
|
||||||
|
resolver: zodResolver(seccionFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
medioId: initialData?.medioId ?? ('' as unknown as number),
|
||||||
|
codigo: initialData?.codigo ?? '',
|
||||||
|
nombre: initialData?.nombre ?? '',
|
||||||
|
tipo: initialData?.tipo ?? ('' as TipoSeccion),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset({
|
||||||
|
medioId: initialData.medioId,
|
||||||
|
codigo: initialData.codigo,
|
||||||
|
nombre: initialData.nombre,
|
||||||
|
tipo: initialData.tipo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="medioId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Medio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
aria-label="Medio"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un medio</option>
|
||||||
|
{medios.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="Ej: CLASIF01"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Nombre de la sección"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tipo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tipo de sección"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un tipo</option>
|
||||||
|
{TIPO_SECCION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear sección'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
import { useMediosList } from '@/features/medios/hooks/useMediosList'
|
||||||
|
import { TIPO_SECCION_OPTIONS } from '../tipoSeccion'
|
||||||
|
import type { TipoSeccion } from '../types'
|
||||||
|
|
||||||
|
interface SeccionesFiltersProps {
|
||||||
|
onMedioIdChange: (medioId: number | undefined) => void
|
||||||
|
onTipoChange: (tipo: TipoSeccion | undefined) => void
|
||||||
|
onActivoChange: (activo: boolean | undefined) => void
|
||||||
|
onSearchChange: (q: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionesFilters({
|
||||||
|
onMedioIdChange,
|
||||||
|
onTipoChange,
|
||||||
|
onActivoChange,
|
||||||
|
onSearchChange,
|
||||||
|
}: SeccionesFiltersProps) {
|
||||||
|
const [searchRaw, setSearchRaw] = useState('')
|
||||||
|
const debouncedSearch = useDebouncedValue(searchRaw, 300)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChange(debouncedSearch)
|
||||||
|
}, [debouncedSearch, onSearchChange])
|
||||||
|
|
||||||
|
// Load medios for the selector
|
||||||
|
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true })
|
||||||
|
const medios = mediosData?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por código, nombre..."
|
||||||
|
value={searchRaw}
|
||||||
|
onChange={(e) => setSearchRaw(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Buscar secciones"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Medio"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onMedioIdChange(v === '' ? undefined : Number(v))
|
||||||
|
}}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todos los medios</option>
|
||||||
|
{medios.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Tipo de sección"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onTipoChange(v === '' ? undefined : (v as TipoSeccion))
|
||||||
|
}}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{TIPO_SECCION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Estado"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '') onActivoChange(undefined)
|
||||||
|
else onActivoChange(v === 'true')
|
||||||
|
}}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/web/src/features/secciones/components/SeccionesTable.tsx
Normal file
103
src/web/src/features/secciones/components/SeccionesTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import type { SeccionListItem } from '../types'
|
||||||
|
import { tipoSeccionLabel } from '../tipoSeccion'
|
||||||
|
import { DeactivateSeccionModal } from './DeactivateSeccionModal'
|
||||||
|
|
||||||
|
interface SeccionesTableProps {
|
||||||
|
rows: SeccionListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionesTable({ rows }: SeccionesTableProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<SeccionListItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'codigo',
|
||||||
|
header: 'Código',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.codigo}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'nombre',
|
||||||
|
header: 'Nombre',
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'tipo',
|
||||||
|
header: 'Tipo',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{tipoSeccionLabel(row.original.tipo)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'medioId',
|
||||||
|
header: 'Medio ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">{row.original.medioId}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'activo',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.activo ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CanPerform permission="administracion:secciones:gestionar">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/secciones/${row.original.id}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateSeccionModal
|
||||||
|
seccionId={row.original.id}
|
||||||
|
seccionNombre={row.original.nombre}
|
||||||
|
activo={row.original.activo}
|
||||||
|
/>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => navigate(`/admin/secciones/${row.id}`)}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
emptyMessage="Sin resultados — no se encontraron secciones con los filtros seleccionados."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/secciones/hooks/useCreateSeccion.ts
Normal file
13
src/web/src/features/secciones/hooks/useCreateSeccion.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createSeccion } from '../api/createSeccion'
|
||||||
|
import type { CreateSeccionRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateSeccion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateSeccionRequest) => createSeccion(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/secciones/hooks/useDeactivateSeccion.ts
Normal file
12
src/web/src/features/secciones/hooks/useDeactivateSeccion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateSeccion } from '../api/deactivateSeccion'
|
||||||
|
|
||||||
|
export function useDeactivateSeccion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateSeccion(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/secciones/hooks/useReactivateSeccion.ts
Normal file
12
src/web/src/features/secciones/hooks/useReactivateSeccion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reactivateSeccion } from '../api/reactivateSeccion'
|
||||||
|
|
||||||
|
export function useReactivateSeccion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateSeccion(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/web/src/features/secciones/hooks/useSeccion.ts
Normal file
11
src/web/src/features/secciones/hooks/useSeccion.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getSeccion } from '../api/getSeccion'
|
||||||
|
|
||||||
|
export function useSeccion(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['secciones', 'detail', id],
|
||||||
|
queryFn: () => getSeccion(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/secciones/hooks/useSeccionesList.ts
Normal file
13
src/web/src/features/secciones/hooks/useSeccionesList.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listSecciones } from '../api/listSecciones'
|
||||||
|
import type { SeccionesQuery } from '../types'
|
||||||
|
|
||||||
|
export const seccionesListQueryKey = (query: SeccionesQuery) => ['secciones', 'list', query] as const
|
||||||
|
|
||||||
|
export function useSeccionesList(query: SeccionesQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: seccionesListQueryKey(query),
|
||||||
|
queryFn: () => listSecciones(query),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/secciones/hooks/useUpdateSeccion.ts
Normal file
13
src/web/src/features/secciones/hooks/useUpdateSeccion.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateSeccion } from '../api/updateSeccion'
|
||||||
|
import type { UpdateSeccionRequest } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateSeccion(id: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateSeccionRequest) => updateSeccion(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
50
src/web/src/features/secciones/pages/CreateSeccionPage.tsx
Normal file
50
src/web/src/features/secciones/pages/CreateSeccionPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { SeccionForm } from '../components/SeccionForm'
|
||||||
|
import { useCreateSeccion } from '../hooks/useCreateSeccion'
|
||||||
|
import type { SeccionFormValues } from '../components/SeccionForm'
|
||||||
|
|
||||||
|
export function CreateSeccionPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { mutate, isPending, error } = useCreateSeccion()
|
||||||
|
|
||||||
|
function handleSubmit(values: SeccionFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
medioId: values.medioId,
|
||||||
|
codigo: values.codigo,
|
||||||
|
nombre: values.nombre,
|
||||||
|
tipo: values.tipo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Sección creada correctamente')
|
||||||
|
void navigate('/admin/secciones')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Crear Sección</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Completá los datos para registrar una nueva sección en el sistema.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SeccionForm isPending={isPending} error={error} onSubmit={handleSubmit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/web/src/features/secciones/pages/EditSeccionPage.tsx
Normal file
80
src/web/src/features/secciones/pages/EditSeccionPage.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { SeccionForm } from '../components/SeccionForm'
|
||||||
|
import { useSeccion } from '../hooks/useSeccion'
|
||||||
|
import { useUpdateSeccion } from '../hooks/useUpdateSeccion'
|
||||||
|
import type { SeccionFormValues } from '../components/SeccionForm'
|
||||||
|
|
||||||
|
export function EditSeccionPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const seccionId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: seccion, isLoading } = useSeccion(seccionId)
|
||||||
|
const { mutate, isPending, error } = useUpdateSeccion(seccionId)
|
||||||
|
|
||||||
|
function handleSubmit(values: SeccionFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
nombre: values.nombre,
|
||||||
|
tipo: values.tipo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Sección actualizada correctamente')
|
||||||
|
void navigate(`/admin/secciones/${seccionId}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seccion) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Sección no encontrada.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl">Editar Sección</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/secciones')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Editá los datos de la sección <strong>{seccion.nombre}</strong>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SeccionForm
|
||||||
|
initialData={seccion}
|
||||||
|
isPending={isPending}
|
||||||
|
error={error}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
99
src/web/src/features/secciones/pages/SeccionDetailPage.tsx
Normal file
99
src/web/src/features/secciones/pages/SeccionDetailPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useSeccion } from '../hooks/useSeccion'
|
||||||
|
import { DeactivateSeccionModal } from '../components/DeactivateSeccionModal'
|
||||||
|
import { tipoSeccionLabel } from '../tipoSeccion'
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('es-AR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const seccionId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: seccion, isLoading } = useSeccion(seccionId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seccion) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Sección no encontrada.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">{seccion.nombre}</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/secciones')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-4 space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Código</span>
|
||||||
|
<span className="font-mono">{seccion.codigo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Medio ID</span>
|
||||||
|
<span>{seccion.medioId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tipo</span>
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{tipoSeccionLabel(seccion.tipo)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Estado</span>
|
||||||
|
{seccion.activo
|
||||||
|
? <Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Activo</Badge>
|
||||||
|
: <Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Inactivo</Badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Creado</span>
|
||||||
|
<span>{formatDate(seccion.fechaCreacion)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Modificado</span>
|
||||||
|
<span>{formatDate(seccion.fechaModificacion)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CanPerform permission="administracion:secciones:gestionar">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/admin/secciones/${seccionId}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateSeccionModal
|
||||||
|
seccionId={seccionId}
|
||||||
|
seccionNombre={seccion.nombre}
|
||||||
|
activo={seccion.activo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/web/src/features/secciones/pages/SeccionesListPage.tsx
Normal file
114
src/web/src/features/secciones/pages/SeccionesListPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { SeccionesTable } from '../components/SeccionesTable'
|
||||||
|
import { SeccionesFilters } from '../components/SeccionesFilters'
|
||||||
|
import { useSeccionesList } from '../hooks/useSeccionesList'
|
||||||
|
import type { TipoSeccion } from '../types'
|
||||||
|
|
||||||
|
export function SeccionesListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [medioId, setMedioId] = useState<number | undefined>(undefined)
|
||||||
|
const [tipo, setTipo] = useState<TipoSeccion | undefined>(undefined)
|
||||||
|
const [activo, setActivo] = useState<boolean | undefined>(undefined)
|
||||||
|
const [q, setQ] = useState<string>('')
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(medioId !== undefined ? { medioId } : {}),
|
||||||
|
...(tipo !== undefined ? { tipo } : {}),
|
||||||
|
...(activo !== undefined ? { activo } : {}),
|
||||||
|
...(q ? { q } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useSeccionesList(query)
|
||||||
|
|
||||||
|
const handleMedioIdChange = useCallback((value: number | undefined) => {
|
||||||
|
setMedioId(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTipoChange = useCallback((value: TipoSeccion | undefined) => {
|
||||||
|
setTipo(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleActivoChange = useCallback((value: boolean | undefined) => {
|
||||||
|
setActivo(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setQ(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Secciones</h1>
|
||||||
|
<CanPerform permission="administracion:secciones:gestionar">
|
||||||
|
<Button onClick={() => navigate('/admin/secciones/nuevo')} size="sm">
|
||||||
|
Nueva sección
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SeccionesFilters
|
||||||
|
onMedioIdChange={handleMedioIdChange}
|
||||||
|
onTipoChange={handleTipoChange}
|
||||||
|
onActivoChange={handleActivoChange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SeccionesTable rows={data?.items ?? []} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} sección${data.total !== 1 ? 'es' : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/web/src/features/secciones/tipoSeccion.ts
Normal file
12
src/web/src/features/secciones/tipoSeccion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { TipoSeccion } from './types'
|
||||||
|
|
||||||
|
export const TIPO_SECCION_OPTIONS: { value: TipoSeccion; label: string }[] = [
|
||||||
|
{ value: 'clasificados', label: 'Clasificados' },
|
||||||
|
{ value: 'notables', label: 'Notables' },
|
||||||
|
{ value: 'suplementos', label: 'Suplementos' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function tipoSeccionLabel(tipo: TipoSeccion): string {
|
||||||
|
const found = TIPO_SECCION_OPTIONS.find((o) => o.value === tipo)
|
||||||
|
return found ? found.label : tipo
|
||||||
|
}
|
||||||
60
src/web/src/features/secciones/types.ts
Normal file
60
src/web/src/features/secciones/types.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// ADM-001 — shared types for secciones feature
|
||||||
|
|
||||||
|
export type TipoSeccion = 'clasificados' | 'notables' | 'suplementos'
|
||||||
|
|
||||||
|
export interface SeccionListItem {
|
||||||
|
id: number
|
||||||
|
medioId: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: TipoSeccion
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeccionDetail {
|
||||||
|
id: number
|
||||||
|
medioId: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: TipoSeccion
|
||||||
|
activo: boolean
|
||||||
|
fechaCreacion: string
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeccionCreated {
|
||||||
|
id: number
|
||||||
|
medioId: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: TipoSeccion
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSeccionRequest {
|
||||||
|
medioId: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: TipoSeccion
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSeccionRequest {
|
||||||
|
nombre: string
|
||||||
|
tipo: TipoSeccion
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeccionesQuery {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
medioId?: number
|
||||||
|
tipo?: TipoSeccion
|
||||||
|
activo?: boolean
|
||||||
|
q?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@ import { NewRolPage } from './features/roles/pages/NewRolPage'
|
|||||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||||
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
||||||
import { AuditPage } from './pages/admin/audit/AuditPage'
|
import { AuditPage } from './pages/admin/audit/AuditPage'
|
||||||
|
import { MediosListPage } from './features/medios/pages/MediosListPage'
|
||||||
|
import { CreateMedioPage } from './features/medios/pages/CreateMedioPage'
|
||||||
|
import { EditMedioPage } from './features/medios/pages/EditMedioPage'
|
||||||
|
import { MedioDetailPage } from './features/medios/pages/MedioDetailPage'
|
||||||
|
import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage'
|
||||||
|
import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage'
|
||||||
|
import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage'
|
||||||
|
import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage'
|
||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { PublicLayout } from './layouts/PublicLayout'
|
import { PublicLayout } from './layouts/PublicLayout'
|
||||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||||
@@ -164,6 +172,74 @@ export function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Medios routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/medios"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:medios:gestionar']}>
|
||||||
|
<MediosListPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/medios/nuevo"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:medios:gestionar']}>
|
||||||
|
<CreateMedioPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/medios/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:medios:gestionar']}>
|
||||||
|
<MedioDetailPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/medios/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:medios:gestionar']}>
|
||||||
|
<EditMedioPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Secciones routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/secciones"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:secciones:gestionar']}>
|
||||||
|
<SeccionesListPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/secciones/nuevo"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:secciones:gestionar']}>
|
||||||
|
<CreateSeccionPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/secciones/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:secciones:gestionar']}>
|
||||||
|
<SeccionDetailPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/secciones/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedPage requiredPermissions={['administracion:secciones:gestionar']}>
|
||||||
|
<EditSeccionPage />
|
||||||
|
</ProtectedPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
96
src/web/src/tests/features/medios/CreateMedioPage.test.tsx
Normal file
96
src/web/src/tests/features/medios/CreateMedioPage.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { CreateMedioPage } from '../../../features/medios/pages/CreateMedioPage'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CreateMedioPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateMedioPage', () => {
|
||||||
|
it('renders the create form with correct title', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByRole('heading', { name: /crear medio/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('successfully creates a medio and navigates away', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ id: 10, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true },
|
||||||
|
{ status: 201 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'RAD01')
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Radio AM')
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '2')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/admin/medios'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows backend error when codigo is duplicated', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ error: 'medio_codigo_duplicado', message: 'Ya existe un medio con ese código' },
|
||||||
|
{ status: 409 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'DUP01')
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(/ya existe un medio con ese código/i),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit button is labeled "Crear medio"', () => {
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByRole('button', { name: /crear medio/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { DeactivateMedioModal } from '../../../features/medios/components/DeactivateMedioModal'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderModal(activo = true) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<DeactivateMedioModal medioId={1} medioNombre="Diario El Día" activo={activo} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeactivateMedioModal', () => {
|
||||||
|
it('shows "Desactivar" trigger when medio is active', () => {
|
||||||
|
renderModal(true)
|
||||||
|
expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Reactivar" trigger when medio is inactive', () => {
|
||||||
|
renderModal(false)
|
||||||
|
expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dialog and shows confirmation text', async () => {
|
||||||
|
renderModal(true)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/desactivar medio/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/diario el día/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls deactivate endpoint on confirm and invalidates query', async () => {
|
||||||
|
let called = false
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/medios/1/deactivate`, () => {
|
||||||
|
called = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderModal(true)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
await waitFor(() => screen.getByRole('alertdialog'))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar$/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(called).toBe(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls reactivate endpoint on confirm when inactive', async () => {
|
||||||
|
let called = false
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/medios/1/reactivate`, () => {
|
||||||
|
called = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderModal(false)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /reactivar/i }))
|
||||||
|
await waitFor(() => screen.getByRole('alertdialog'))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /reactivar$/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(called).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
112
src/web/src/tests/features/medios/EditMedioPage.test.tsx
Normal file
112
src/web/src/tests/features/medios/EditMedioPage.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { EditMedioPage } from '../../../features/medios/pages/EditMedioPage'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sampleMedio = {
|
||||||
|
id: 5,
|
||||||
|
codigo: 'WEB01',
|
||||||
|
nombre: 'Portal Web',
|
||||||
|
tipo: 3,
|
||||||
|
plataformaEmpresaId: 42,
|
||||||
|
activo: true,
|
||||||
|
fechaCreacion: '2026-01-01T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage(id = '5') {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[`/admin/medios/${id}/edit`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/medios/:id/edit" element={<EditMedioPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EditMedioPage', () => {
|
||||||
|
it('shows loading state initially', () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios/5`, () =>
|
||||||
|
HttpResponse.json(sampleMedio),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
expect(screen.getByText(/cargando/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads and pre-fills form with medio data', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios/5`, () =>
|
||||||
|
HttpResponse.json(sampleMedio),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Portal Web'),
|
||||||
|
)
|
||||||
|
expect((screen.getByLabelText(/código/i) as HTMLInputElement).value).toBe('WEB01')
|
||||||
|
// Código disabled in edit mode
|
||||||
|
expect((screen.getByLabelText(/código/i) as HTMLInputElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Medio no encontrado" when 404', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios/999`, () =>
|
||||||
|
new HttpResponse(null, { status: 404 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage('999')
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/medio no encontrado/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Guardar cambios" button in edit mode', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios/5`, () =>
|
||||||
|
HttpResponse.json(sampleMedio),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
110
src/web/src/tests/features/medios/MedioForm.test.tsx
Normal file
110
src/web/src/tests/features/medios/MedioForm.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { MedioForm } from '../../../features/medios/components/MedioForm'
|
||||||
|
import type { MedioFormValues } from '../../../features/medios/components/MedioForm'
|
||||||
|
import type { MedioDetail } from '../../../features/medios/types'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sampleMedio: MedioDetail = {
|
||||||
|
id: 1,
|
||||||
|
codigo: 'DIA01',
|
||||||
|
nombre: 'Diario Principal',
|
||||||
|
tipo: 1,
|
||||||
|
plataformaEmpresaId: null,
|
||||||
|
activo: true,
|
||||||
|
fechaCreacion: '2026-01-01T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(opts: { initialData?: MedioDetail; onSubmit?: (v: MedioFormValues) => void } = {}) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onSubmit = opts.onSubmit ?? vi.fn()
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<MedioForm
|
||||||
|
initialData={opts.initialData}
|
||||||
|
isPending={false}
|
||||||
|
error={null}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onSubmit }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MedioForm — create mode', () => {
|
||||||
|
it('shows validation error when código is empty', async () => {
|
||||||
|
renderForm()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/código es requerido/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when nombre is empty', async () => {
|
||||||
|
renderForm()
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'COD1')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when tipo is not selected', async () => {
|
||||||
|
renderForm()
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'COD1')
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Medio')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
|
||||||
|
// The form message for tipo validation appears as a <p> role
|
||||||
|
await waitFor(() => {
|
||||||
|
const messages = screen.getAllByText(/seleccioná un tipo/i)
|
||||||
|
// At least one message should exist (validation error, not just the placeholder option)
|
||||||
|
expect(messages.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with correct values on valid form', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
renderForm({ onSubmit })
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'DIA99')
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Diario')
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/tipo/i), '1')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalled()
|
||||||
|
const firstArg = onSubmit.mock.calls[0][0]
|
||||||
|
expect(firstArg).toMatchObject({ codigo: 'DIA99', nombre: 'Mi Diario', tipo: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('MedioForm — edit mode', () => {
|
||||||
|
it('código field is disabled in edit mode', () => {
|
||||||
|
renderForm({ initialData: sampleMedio })
|
||||||
|
const codigoInput = screen.getByLabelText(/código/i) as HTMLInputElement
|
||||||
|
expect(codigoInput.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills form with initialData values', () => {
|
||||||
|
renderForm({ initialData: sampleMedio })
|
||||||
|
expect((screen.getByLabelText(/código/i) as HTMLInputElement).value).toBe('DIA01')
|
||||||
|
expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Diario Principal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Guardar cambios" button in edit mode', () => {
|
||||||
|
renderForm({ initialData: sampleMedio })
|
||||||
|
expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
168
src/web/src/tests/features/medios/MediosListPage.test.tsx
Normal file
168
src/web/src/tests/features/medios/MediosListPage.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { MediosListPage } from '../../../features/medios/pages/MediosListPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUserWithMedios = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:medios:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserWithoutMedios = {
|
||||||
|
id: 2,
|
||||||
|
username: 'cajero',
|
||||||
|
nombre: 'Cajero',
|
||||||
|
rol: 'cajero',
|
||||||
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMedios(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
codigo: `MEDIO${i + 1}`,
|
||||||
|
nombre: `Medio ${i + 1}`,
|
||||||
|
tipo: (i % 4) + 1,
|
||||||
|
plataformaEmpresaId: null,
|
||||||
|
activo: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage(user = adminUserWithMedios) {
|
||||||
|
useAuthStore.setState({ user })
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/admin/medios']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/medios" element={<MediosListPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MediosListPage', () => {
|
||||||
|
it('renders seed rows when API returns items', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('MEDIO2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('MEDIO3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when items is empty', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Nuevo medio" button when user lacks permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage(adminUserWithoutMedios)
|
||||||
|
|
||||||
|
// Wait for page to render
|
||||||
|
await waitFor(() => expect(screen.queryByRole('button', { name: /nuevo medio/i })).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Nuevo medio" button when user has permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: /nuevo medio/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filter by tipo adds querystring tipo', async () => {
|
||||||
|
const requests: string[] = []
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, ({ request }) => {
|
||||||
|
requests.push(request.url)
|
||||||
|
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
|
||||||
|
|
||||||
|
const tipoSelect = screen.getByRole('combobox', { name: /tipo/i })
|
||||||
|
await userEvent.selectOptions(tipoSelect, '1')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const filtered = requests.find((u) => u.includes('tipo=1'))
|
||||||
|
expect(filtered).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prev button disabled on first page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { DeactivateSeccionModal } from '../../../features/secciones/components/DeactivateSeccionModal'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderModal(activo = true) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<DeactivateSeccionModal seccionId={1} seccionNombre="Clasificados Autos" activo={activo} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeactivateSeccionModal', () => {
|
||||||
|
it('shows "Desactivar" trigger when sección is active', () => {
|
||||||
|
renderModal(true)
|
||||||
|
expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Reactivar" trigger when sección is inactive', () => {
|
||||||
|
renderModal(false)
|
||||||
|
expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dialog and shows confirmation text', async () => {
|
||||||
|
renderModal(true)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/desactivar sección/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/clasificados autos/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls deactivate endpoint on confirm', async () => {
|
||||||
|
let called = false
|
||||||
|
server.use(
|
||||||
|
http.post(`${API_URL}/api/v1/admin/secciones/1/deactivate`, () => {
|
||||||
|
called = true
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderModal(true)
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
|
||||||
|
await waitFor(() => screen.getByRole('alertdialog'))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /desactivar$/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(called).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
136
src/web/src/tests/features/secciones/SeccionForm.test.tsx
Normal file
136
src/web/src/tests/features/secciones/SeccionForm.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { SeccionForm } from '../../../features/secciones/components/SeccionForm'
|
||||||
|
import type { SeccionFormValues } from '../../../features/secciones/components/SeccionForm'
|
||||||
|
import type { SeccionDetail } from '../../../features/secciones/types'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockMedios = [
|
||||||
|
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
|
||||||
|
{ id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sampleSeccion: SeccionDetail = {
|
||||||
|
id: 1,
|
||||||
|
medioId: 1,
|
||||||
|
codigo: 'CLAS01',
|
||||||
|
nombre: 'Clasificados Autos',
|
||||||
|
tipo: 'clasificados',
|
||||||
|
activo: true,
|
||||||
|
fechaCreacion: '2026-01-01T00:00:00Z',
|
||||||
|
fechaModificacion: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderForm(opts: { initialData?: SeccionDetail; onSubmit?: (v: SeccionFormValues) => void } = {}) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
const onSubmit = opts.onSubmit ?? vi.fn()
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<SeccionForm
|
||||||
|
initialData={opts.initialData}
|
||||||
|
isPending={false}
|
||||||
|
error={null}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return { onSubmit }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SeccionForm — create mode', () => {
|
||||||
|
it('shows validation error when código is empty', async () => {
|
||||||
|
renderForm()
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear sección/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/código es requerido/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when tipo is not selected', async () => {
|
||||||
|
renderForm()
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'CLAS99')
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Sección')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear sección/i }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/seleccioná un tipo/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with correct values on valid form', async () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
renderForm({ onSubmit })
|
||||||
|
|
||||||
|
// Wait for medios to load
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1')
|
||||||
|
await userEvent.type(screen.getByLabelText(/código/i), 'CLAS99')
|
||||||
|
await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Sección')
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/tipo de sección/i), 'clasificados')
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /crear sección/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalled()
|
||||||
|
const firstArg = onSubmit.mock.calls[0][0]
|
||||||
|
expect(firstArg).toMatchObject({
|
||||||
|
medioId: 1,
|
||||||
|
codigo: 'CLAS99',
|
||||||
|
nombre: 'Mi Sección',
|
||||||
|
tipo: 'clasificados',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SeccionForm — edit mode', () => {
|
||||||
|
it('código and medioId are disabled in edit mode', async () => {
|
||||||
|
renderForm({ initialData: sampleSeccion })
|
||||||
|
const codigoInput = screen.getByLabelText(/código/i) as HTMLInputElement
|
||||||
|
expect(codigoInput.disabled).toBe(true)
|
||||||
|
const medioSelect = screen.getByLabelText(/medio/i) as HTMLSelectElement
|
||||||
|
expect(medioSelect.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills form with initialData values', async () => {
|
||||||
|
renderForm({ initialData: sampleSeccion })
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Clasificados Autos'),
|
||||||
|
)
|
||||||
|
expect((screen.getByLabelText(/código/i) as HTMLInputElement).value).toBe('CLAS01')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Guardar cambios" button in edit mode', () => {
|
||||||
|
renderForm({ initialData: sampleSeccion })
|
||||||
|
expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
103
src/web/src/tests/features/secciones/SeccionesFilters.test.tsx
Normal file
103
src/web/src/tests/features/secciones/SeccionesFilters.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { SeccionesFilters } from '../../../features/secciones/components/SeccionesFilters'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockMedios = [
|
||||||
|
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
|
||||||
|
{ id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true },
|
||||||
|
{ id: 3, codigo: 'WEB01', nombre: 'Portal Web', tipo: 3, plataformaEmpresaId: null, activo: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderFilters(handlers = {
|
||||||
|
onMedioIdChange: vi.fn(),
|
||||||
|
onTipoChange: vi.fn(),
|
||||||
|
onActivoChange: vi.fn(),
|
||||||
|
onSearchChange: vi.fn(),
|
||||||
|
}) {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 3 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<SeccionesFilters {...handlers} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
return handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SeccionesFilters', () => {
|
||||||
|
it('loads medios options from API', async () => {
|
||||||
|
renderFilters()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('option', { name: 'Radio AM' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('option', { name: 'Portal Web' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onMedioIdChange when medio is selected', async () => {
|
||||||
|
const handlers = renderFilters()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('option', { name: 'Diario El Día' })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/medio/i), '1')
|
||||||
|
expect(handlers.onMedioIdChange).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onTipoChange when tipo is selected', async () => {
|
||||||
|
const handlers = renderFilters()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/tipo de sección/i), 'clasificados')
|
||||||
|
expect(handlers.onTipoChange).toHaveBeenCalledWith('clasificados')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onActivoChange when estado is selected', async () => {
|
||||||
|
const handlers = renderFilters()
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText(/estado/i), 'true')
|
||||||
|
expect(handlers.onActivoChange).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders all tipo options', async () => {
|
||||||
|
renderFilters()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('option', { name: 'Notables' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('option', { name: 'Suplementos' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
166
src/web/src/tests/features/secciones/SeccionesListPage.test.tsx
Normal file
166
src/web/src/tests/features/secciones/SeccionesListPage.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { SeccionesListPage } from '../../../features/secciones/pages/SeccionesListPage'
|
||||||
|
import { useAuthStore } from '../../../stores/authStore'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn()
|
||||||
|
vi.mock('react-router-dom', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||||||
|
return { ...actual, useNavigate: () => mockNavigate }
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminWithSecciones = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nombre: 'Admin',
|
||||||
|
rol: 'admin',
|
||||||
|
permisos: ['administracion:secciones:gestionar'],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const userWithoutSecciones = {
|
||||||
|
id: 2,
|
||||||
|
username: 'cajero',
|
||||||
|
nombre: 'Cajero',
|
||||||
|
rol: 'cajero',
|
||||||
|
permisos: [],
|
||||||
|
mustChangePassword: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMedios = [
|
||||||
|
{ id: 1, codigo: 'DIA01', nombre: 'Diario El Día', tipo: 1, plataformaEmpresaId: null, activo: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function makeSecciones(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
medioId: 1,
|
||||||
|
codigo: `SEC${i + 1}`,
|
||||||
|
nombre: `Sección ${i + 1}`,
|
||||||
|
tipo: 'clasificados',
|
||||||
|
activo: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers()
|
||||||
|
useAuthStore.getState().clearAuth()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderPage(user = adminWithSecciones) {
|
||||||
|
useAuthStore.setState({ user })
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={['/admin/secciones']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/admin/secciones" element={<SeccionesListPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SeccionesListPage', () => {
|
||||||
|
it('renders seed rows when API returns items', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/secciones`, () =>
|
||||||
|
HttpResponse.json({ items: makeSecciones(3), page: 1, pageSize: 20, total: 3 }),
|
||||||
|
),
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('SEC1')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('SEC2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SEC3')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when items is empty', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/secciones`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides "Nueva sección" button when user lacks permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/secciones`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage(userWithoutSecciones)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole('button', { name: /nueva sección/i })).not.toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Nueva sección" button when user has permission', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/secciones`, () =>
|
||||||
|
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
|
||||||
|
),
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('button', { name: /nueva sección/i })).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prev button disabled on first page', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/admin/secciones`, () =>
|
||||||
|
HttpResponse.json({ items: makeSecciones(3), page: 1, pageSize: 20, total: 3 }),
|
||||||
|
),
|
||||||
|
http.get(`${API_URL}/api/v1/admin/medios`, () =>
|
||||||
|
HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 1 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('SEC1')).toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user