feat: PRD-002 Product CRUD #40

Merged
dmolinari merged 14 commits from feature/PRD-002 into main 2026-04-19 16:49:58 +00:00
2 changed files with 156 additions and 1 deletions
Showing only changes of commit a7cfcdb683 - Show all commits

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { AlertCircle, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { CanPerform } from '@/components/auth/CanPerform'
@@ -11,6 +12,8 @@ import { ProductFormDialog } from '../components/ProductFormDialog'
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
import type { ProductListItem, ProductDetail } from '../types'
const PAGE_SIZE = 20
export function ProductsPage() {
// ── Create dialog state ──────────────────────────────────────────────────
const [createOpen, setCreateOpen] = useState(false)
@@ -23,7 +26,16 @@ export function ProductsPage() {
const [deactivateOpen, setDeactivateOpen] = useState(false)
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
const { data: paged, isLoading, isError } = useProducts({ activo: true })
// ── Pagination & filter state ────────────────────────────────────────────
const [page, setPage] = useState(1)
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
const { data: paged, isLoading, isError } = useProducts({
activo: true,
page,
pageSize: PAGE_SIZE,
medioId: medioIdFilter,
})
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
// ── Handlers ─────────────────────────────────────────────────────────────
@@ -52,6 +64,14 @@ export function ProductsPage() {
toast.success('Producto desactivado')
}
function handleMedioFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value
setPage(1)
setMedioIdFilter(val ? Number(val) : undefined)
}
const totalPages = paged ? Math.ceil(paged.total / PAGE_SIZE) : 1
// ── Loading / Error ───────────────────────────────────────────────────────
if (isLoading) {
@@ -86,6 +106,18 @@ export function ProductsPage() {
</CanPerform>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<Input
type="number"
min={1}
placeholder="Filtrar por ID de Medio"
aria-label="Filtrar por ID de Medio"
className="w-52"
onChange={handleMedioFilterChange}
/>
</div>
{isEmpty ? (
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
<p>No hay productos.</p>
@@ -148,6 +180,33 @@ export function ProductsPage() {
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
aria-label="Página anterior"
>
Anterior
</Button>
<span className="text-sm text-muted-foreground">
Página {page} de {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
aria-label="Página siguiente"
>
Siguiente
</Button>
</div>
)}
{/* Create dialog */}
<ProductFormDialog
open={createOpen}

View File

@@ -185,3 +185,99 @@ describe('ProductsPage — deactivate dialog', () => {
)
})
})
// ─── Pagination ──────────────────────────────────────────────────────────────
describe('ProductsPage — pagination', () => {
it('shows pagination controls when total > pageSize', async () => {
// 21 total items but only 1 in this page → totalPages=2 → controls visible
const pagedWith21Total: PagedResult<ProductListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 21,
}
server.use(
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()
})
it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => {
const capturedRequests: URL[] = []
const pagedPage1: PagedResult<ProductListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 21,
}
const pagedPage2: PagedResult<ProductListItem> = {
items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }],
page: 2,
pageSize: 20,
total: 21,
}
server.use(
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
capturedRequests.push(new URL(request.url))
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1)
}),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /siguiente/i }))
await waitFor(() => {
expect(screen.getByText('Producto Página 2')).toBeInTheDocument()
})
// Verify that at least one request was made with page=2
const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2')
expect(page2Requests.length).toBeGreaterThan(0)
})
})
// ─── Filter by Medio ─────────────────────────────────────────────────────────
describe('ProductsPage — filter by Medio', () => {
it('re-fetches with medioId when Medio filter is changed', async () => {
const capturedRequests: URL[] = []
const filteredPaged: PagedResult<ProductListItem> = {
items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }],
page: 1,
pageSize: 20,
total: 1,
}
server.use(
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
capturedRequests.push(new URL(request.url))
const url = new URL(request.url)
const medioId = url.searchParams.get('medioId')
return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged)
}),
)
renderPage(adminUser)
// Wait for initial empty state
await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument())
const filterInput = screen.getByLabelText(/filtrar por id de medio/i)
await userEvent.type(filterInput, '5')
await waitFor(() => {
expect(screen.getByText('Producto Medio 5')).toBeInTheDocument()
})
const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5')
expect(filteredRequests.length).toBeGreaterThan(0)
})
})