From 4b96cdefcc8cc5e1512f92e9e089ce4aabb7740b Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:36:44 -0300 Subject: [PATCH] feat(web): tabla y form PuntosDeVenta --- .../DeactivatePuntoDeVentaModal.tsx | 70 +++++++ .../components/PuntoDeVentaForm.tsx | 177 ++++++++++++++++++ .../components/PuntosDeVentaFilters.tsx | 59 ++++++ .../components/PuntosDeVentaTable.tsx | 95 ++++++++++ .../pages/CreatePuntoDeVentaPage.tsx | 49 +++++ .../pages/EditPuntoDeVentaPage.tsx | 90 +++++++++ .../pages/PuntoDeVentaDetailPage.tsx | 108 +++++++++++ .../pages/PuntosDeVentaListPage.tsx | 111 +++++++++++ 8 files changed, 759 insertions(+) create mode 100644 src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx create mode 100644 src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx create mode 100644 src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx diff --git a/src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx b/src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx new file mode 100644 index 0000000..37b5963 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/DeactivatePuntoDeVentaModal.tsx @@ -0,0 +1,70 @@ +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 { useDeactivatePuntoDeVenta, useReactivatePuntoDeVenta } from '../hooks/usePuntosDeVenta' + +interface DeactivatePuntoDeVentaModalProps { + puntoDeVentaId: number + puntoDeVentaNombre: string + activo: boolean + disabled?: boolean +} + +export function DeactivatePuntoDeVentaModal({ + puntoDeVentaId, + puntoDeVentaNombre, + activo, + disabled = false, +}: DeactivatePuntoDeVentaModalProps) { + const [open, setOpen] = useState(false) + const { mutate: deactivate, isPending: deactivating } = useDeactivatePuntoDeVenta() + const { mutate: reactivate, isPending: reactivating } = useReactivatePuntoDeVenta() + + const isPending = deactivating || reactivating + + function handleConfirm() { + if (activo) { + deactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) }) + } else { + reactivate(puntoDeVentaId, { onSuccess: () => setOpen(false) }) + } + } + + return ( + + + + + + + + {activo ? 'Desactivar punto de venta' : 'Reactivar punto de venta'} + + + {activo + ? `¿Confirmás que querés desactivar el punto de venta "${puntoDeVentaNombre}"?` + : `¿Confirmás que querés reactivar el punto de venta "${puntoDeVentaNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx b/src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx new file mode 100644 index 0000000..ffe279b --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PuntoDeVentaForm.tsx @@ -0,0 +1,177 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useMediosList } from '@/features/medios/hooks/useMediosList' +import type { PuntoDeVentaDetail } from '../types' + +const puntoDeVentaFormSchema = z.object({ + medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'), + numeroAFIP: z.coerce + .number({ invalid_type_error: 'El número AFIP debe ser un número' }) + .int('Debe ser un número entero') + .min(1, 'El número AFIP debe ser mayor a 0'), + nombre: z + .string() + .min(1, 'El nombre es requerido') + .max(100, 'Máximo 100 caracteres'), +}) + +export type PuntoDeVentaFormValues = z.infer + +interface PuntoDeVentaFormProps { + initialData?: PuntoDeVentaDetail + isPending: boolean + error: unknown + onSubmit: (values: PuntoDeVentaFormValues) => 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 === 'numero_afip_duplicado') { + return data.message ?? 'Ya existe un punto de venta con ese número AFIP en el medio' + } + if (data.error === 'medio_inactivo') { + return data.message ?? 'El medio está inactivo. Reactivalo antes de operar.' + } + return data.message ?? data.error ?? 'Error al guardar el punto de venta' + } + return 'Error al guardar el punto de venta' +} + +export function PuntoDeVentaForm({ initialData, isPending, error, onSubmit }: PuntoDeVentaFormProps) { + const isEdit = !!initialData + + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 }) + const medios = mediosData?.items ?? [] + + const form = useForm({ + resolver: zodResolver(puntoDeVentaFormSchema), + defaultValues: { + medioId: initialData?.medioId ?? ('' as unknown as number), + numeroAFIP: initialData?.numeroAFIP ?? ('' as unknown as number), + nombre: initialData?.nombre ?? '', + }, + }) + + useEffect(() => { + if (initialData) { + form.reset({ + medioId: initialData.medioId, + numeroAFIP: initialData.numeroAFIP, + nombre: initialData.nombre, + }) + } + }, [initialData, form]) + + const backendError = resolveBackendError(error) + + return ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Medio + + + + )} + /> + + ( + + Número AFIP + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx new file mode 100644 index 0000000..9fb4b42 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaFilters.tsx @@ -0,0 +1,59 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useMediosList } from '@/features/medios/hooks/useMediosList' + +interface PuntosDeVentaFiltersProps { + onMedioIdChange: (medioId: number | undefined) => void + onActivoChange: (activo: boolean | undefined) => void +} + +export function PuntosDeVentaFilters({ + onMedioIdChange, + onActivoChange, +}: PuntosDeVentaFiltersProps) { + const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true }) + const medios = mediosData?.items ?? [] + + return ( +
+ + + +
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx new file mode 100644 index 0000000..c3dd6d9 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/components/PuntosDeVentaTable.tsx @@ -0,0 +1,95 @@ +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 { PuntoDeVentaListItem } from '../types' +import { DeactivatePuntoDeVentaModal } from './DeactivatePuntoDeVentaModal' + +interface PuntosDeVentaTableProps { + rows: PuntoDeVentaListItem[] + medioInactivo?: boolean +} + +export function PuntosDeVentaTable({ rows, medioInactivo = false }: PuntosDeVentaTableProps) { + const navigate = useNavigate() + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'numeroAFIP', + header: 'N° AFIP', + cell: ({ row }) => ( + {row.original.numeroAFIP} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'medioId', + header: 'Medio ID', + cell: ({ row }) => ( + {row.original.medioId} + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate, medioInactivo], + ) + + return ( + navigate(`/admin/puntos-de-venta/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados — no se encontraron puntos de venta con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx b/src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx new file mode 100644 index 0000000..7d30424 --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/CreatePuntoDeVentaPage.tsx @@ -0,0 +1,49 @@ +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { PuntoDeVentaForm } from '../components/PuntoDeVentaForm' +import { useCreatePuntoDeVenta } from '../hooks/usePuntosDeVenta' +import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm' + +export function CreatePuntoDeVentaPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreatePuntoDeVenta() + + function handleSubmit(values: PuntoDeVentaFormValues) { + mutate( + { + medioId: values.medioId, + numeroAFIP: values.numeroAFIP, + nombre: values.nombre, + }, + { + onSuccess: () => { + toast.success('Punto de venta creado correctamente') + void navigate('/admin/puntos-de-venta') + }, + }, + ) + } + + return ( +
+ + + Crear Punto de Venta + + Completá los datos para registrar un nuevo punto de venta AFIP. + + + + + + +
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx b/src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx new file mode 100644 index 0000000..33a2bbf --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/EditPuntoDeVentaPage.tsx @@ -0,0 +1,90 @@ +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 { PuntoDeVentaForm } from '../components/PuntoDeVentaForm' +import { usePuntoDeVenta, useUpdatePuntoDeVenta } from '../hooks/usePuntosDeVenta' +import { useMedio } from '../../medios/hooks/useMedio' +import { MedioInactivoBanner } from '../components/MedioInactivoBanner' +import type { PuntoDeVentaFormValues } from '../components/PuntoDeVentaForm' + +export function EditPuntoDeVentaPage() { + const { id } = useParams<{ id: string }>() + const puntoDeVentaId = Number(id) + const navigate = useNavigate() + + const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId) + const { mutate, isPending, error } = useUpdatePuntoDeVenta(puntoDeVentaId) + const { data: medio } = useMedio(pdv?.medioId ?? 0) + const medioInactivo = medio?.activo === false + + function handleSubmit(values: PuntoDeVentaFormValues) { + mutate( + { + nombre: values.nombre, + numeroAFIP: values.numeroAFIP, + }, + { + onSuccess: () => { + toast.success('Punto de venta actualizado correctamente') + void navigate(`/admin/puntos-de-venta/${puntoDeVentaId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!pdv) { + return ( +
+ Punto de venta no encontrado. +
+ ) + } + + return ( +
+ + +
+ Editar Punto de Venta + +
+ + Editá los datos del punto de venta {pdv.nombre}. + +
+ + {medioInactivo && medio && ( + + )} + + +
+
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx new file mode 100644 index 0000000..bf7e68c --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/PuntoDeVentaDetailPage.tsx @@ -0,0 +1,108 @@ +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 { usePuntoDeVenta } from '../hooks/usePuntosDeVenta' +import { useMedio } from '../../medios/hooks/useMedio' +import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal' +import { MedioInactivoBanner } from '../components/MedioInactivoBanner' +import { PdvInactivoBanner } from '../components/PdvInactivoBanner' + +function formatDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function PuntoDeVentaDetailPage() { + const { id } = useParams<{ id: string }>() + const puntoDeVentaId = Number(id) + const navigate = useNavigate() + + const { data: pdv, isLoading } = usePuntoDeVenta(puntoDeVentaId) + const { data: medio } = useMedio(pdv?.medioId ?? 0) + const medioInactivo = medio?.activo === false + const pdvInactivo = pdv?.activo === false + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!pdv) { + return ( +
+ Punto de venta no encontrado. +
+ ) + } + + return ( +
+
+

{pdv.nombre}

+ +
+ +
+
+ Número AFIP + {pdv.numeroAFIP} +
+
+ Medio ID + {pdv.medioId} +
+
+ Estado + {pdv.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(pdv.fechaCreacion)} +
+
+ Modificado + {formatDate(pdv.fechaModificacion)} +
+
+ + {pdvInactivo && ( + + )} + + {medioInactivo && medio && ( + + )} + + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx b/src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx new file mode 100644 index 0000000..d52bd5c --- /dev/null +++ b/src/web/src/features/puntos-de-venta/pages/PuntosDeVentaListPage.tsx @@ -0,0 +1,111 @@ +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 { PuntosDeVentaTable } from '../components/PuntosDeVentaTable' +import { PuntosDeVentaFilters } from '../components/PuntosDeVentaFilters' +import { MedioInactivoBanner } from '../components/MedioInactivoBanner' +import { usePuntosDeVentaList } from '../hooks/usePuntosDeVenta' +import { useMedio } from '../../medios/hooks/useMedio' + +export function PuntosDeVentaListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [medioId, setMedioId] = useState(undefined) + const [activo, setActivo] = useState(undefined) + + const query = { + page, + pageSize: 20, + ...(medioId !== undefined ? { medioId } : {}), + ...(activo !== undefined ? { activo } : {}), + } + + const { data, isLoading } = usePuntosDeVentaList(query) + + // Fetch parent medio only when filtering by a single medioId + const { data: filteredMedio } = useMedio(medioId ?? 0) + const medioInactivo = medioId !== undefined && filteredMedio?.activo === false + + const handleMedioIdChange = useCallback((value: number | undefined) => { + setMedioId(value) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: boolean | undefined) => { + setActivo(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Puntos de Venta

+ + + +
+ + {medioInactivo && filteredMedio && ( + + )} + + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} punto${data.total !== 1 ? 's' : ''} de venta` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +}