feat(web): tabla y form PuntosDeVenta
This commit is contained in:
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={disabled}>
|
||||||
|
{activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{activo ? 'Desactivar punto de venta' : 'Reactivar punto de venta'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{activo
|
||||||
|
? `¿Confirmás que querés desactivar el punto de venta "${puntoDeVentaNombre}"?`
|
||||||
|
: `¿Confirmás que querés reactivar el punto de venta "${puntoDeVentaNombre}"?`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof puntoDeVentaFormSchema>
|
||||||
|
|
||||||
|
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<PuntoDeVentaFormValues>({
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
<Select
|
||||||
|
value={field.value ? String(field.value) : ''}
|
||||||
|
onValueChange={(v) => field.onChange(Number(v))}
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full" aria-label="Medio">
|
||||||
|
<SelectValue placeholder="Seleccioná un medio" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{medios.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={String(m.id)}>
|
||||||
|
{m.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="numeroAFIP"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Número AFIP</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="Ej: 1"
|
||||||
|
aria-label="Número AFIP"
|
||||||
|
/>
|
||||||
|
</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 punto de venta"
|
||||||
|
aria-label="Nombre"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear punto de venta'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||||
|
<Select
|
||||||
|
defaultValue="__all__"
|
||||||
|
onValueChange={(v) => onMedioIdChange(v === '__all__' ? undefined : Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-52" aria-label="Medio">
|
||||||
|
<SelectValue placeholder="Todos los medios" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">Todos los medios</SelectItem>
|
||||||
|
{medios.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={String(m.id)}>
|
||||||
|
{m.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
defaultValue="__all__"
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === '__all__') onActivoChange(undefined)
|
||||||
|
else onActivoChange(v === 'true')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-32" aria-label="Estado">
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">Todos</SelectItem>
|
||||||
|
<SelectItem value="true">Activos</SelectItem>
|
||||||
|
<SelectItem value="false">Inactivos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<ColumnDef<PuntoDeVentaListItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'numeroAFIP',
|
||||||
|
header: 'N° AFIP',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.numeroAFIP}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'nombre',
|
||||||
|
header: 'Nombre',
|
||||||
|
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:puntos_de_venta:gestionar">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={medioInactivo}
|
||||||
|
onClick={() => navigate(`/admin/puntos-de-venta/${row.original.id}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivatePuntoDeVentaModal
|
||||||
|
puntoDeVentaId={row.original.id}
|
||||||
|
puntoDeVentaNombre={row.original.nombre}
|
||||||
|
activo={row.original.activo}
|
||||||
|
disabled={medioInactivo}
|
||||||
|
/>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[navigate, medioInactivo],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => 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."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Crear Punto de Venta</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Completá los datos para registrar un nuevo punto de venta AFIP.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PuntoDeVentaForm isPending={isPending} error={error} onSubmit={handleSubmit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pdv) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Punto de venta 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 Punto de Venta</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate('/admin/puntos-de-venta')}
|
||||||
|
>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Editá los datos del punto de venta <strong>{pdv.nombre}</strong>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{medioInactivo && medio && (
|
||||||
|
<MedioInactivoBanner medioNombre={medio.nombre} />
|
||||||
|
)}
|
||||||
|
<PuntoDeVentaForm
|
||||||
|
initialData={pdv}
|
||||||
|
isPending={isPending || medioInactivo}
|
||||||
|
error={error}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pdv) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Punto de venta 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">{pdv.nombre}</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/puntos-de-venta')}>
|
||||||
|
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">Número AFIP</span>
|
||||||
|
<span className="font-mono">{pdv.numeroAFIP}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Medio ID</span>
|
||||||
|
<span>{pdv.medioId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Estado</span>
|
||||||
|
{pdv.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(pdv.fechaCreacion)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Modificado</span>
|
||||||
|
<span>{formatDate(pdv.fechaModificacion)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pdvInactivo && (
|
||||||
|
<PdvInactivoBanner puntoDeVentaNombre={pdv.nombre} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{medioInactivo && medio && (
|
||||||
|
<MedioInactivoBanner medioNombre={medio.nombre} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CanPerform permission="administracion:puntos_de_venta:gestionar">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={medioInactivo}
|
||||||
|
onClick={() => navigate(`/admin/puntos-de-venta/${puntoDeVentaId}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivatePuntoDeVentaModal
|
||||||
|
puntoDeVentaId={puntoDeVentaId}
|
||||||
|
puntoDeVentaNombre={pdv.nombre}
|
||||||
|
activo={pdv.activo}
|
||||||
|
disabled={medioInactivo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<number | undefined>(undefined)
|
||||||
|
const [activo, setActivo] = useState<boolean | undefined>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Puntos de Venta</h1>
|
||||||
|
<CanPerform permission="administracion:puntos_de_venta:gestionar">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/admin/puntos-de-venta/nuevo')}
|
||||||
|
size="sm"
|
||||||
|
disabled={medioInactivo}
|
||||||
|
>
|
||||||
|
Nuevo punto de venta
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{medioInactivo && filteredMedio && (
|
||||||
|
<MedioInactivoBanner medioNombre={filteredMedio.nombre} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PuntosDeVentaFilters
|
||||||
|
onMedioIdChange={handleMedioIdChange}
|
||||||
|
onActivoChange={handleActivoChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PuntosDeVentaTable rows={data?.items ?? []} medioInactivo={medioInactivo} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} punto${data.total !== 1 ? 's' : ''} de venta` : ''}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user