feat(frontend): pagination UI on product prices history (refs #47)

This commit is contained in:
2026-04-19 19:52:45 -03:00
parent 0dce3ee4ac
commit 34b07a1d55
6 changed files with 282 additions and 56 deletions

View File

@@ -1,7 +1,14 @@
import { axiosClient } from '@/api/axiosClient' import { axiosClient } from '@/api/axiosClient'
import type { ProductPrice } from '../types' import type { PagedResult, ProductPrice } from '../types'
export async function getProductPrices(productId: number): Promise<ProductPrice[]> { export async function getProductPrices(
const res = await axiosClient.get<ProductPrice[]>(`/api/v1/products/${productId}/prices`) productId: number,
page: number = 1,
pageSize: number = 20,
): Promise<PagedResult<ProductPrice>> {
const res = await axiosClient.get<PagedResult<ProductPrice>>(
`/api/v1/products/${productId}/prices`,
{ params: { page, pageSize } },
)
return res.data return res.data
} }

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { AlertCircle, Plus } from 'lucide-react' import { AlertCircle, ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -17,6 +17,10 @@ import { formatCivilDate, formatCurrency } from '@/lib/formatters'
import { useProductPrices } from '../hooks/useProductPrices' import { useProductPrices } from '../hooks/useProductPrices'
import { AddProductPriceDialog } from './AddProductPriceDialog' import { AddProductPriceDialog } from './AddProductPriceDialog'
// ─── Constants ────────────────────────────────────────────────────────────────
const PAGE_SIZE = 20
// ─── Props ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
interface ProductPriceHistoryProps { interface ProductPriceHistoryProps {
@@ -27,7 +31,9 @@ interface ProductPriceHistoryProps {
export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
const [addOpen, setAddOpen] = useState(false) const [addOpen, setAddOpen] = useState(false)
const { data: prices, isLoading, isError } = useProductPrices(productId) const [currentPage, setCurrentPage] = useState(1)
const { data: prices, isLoading, isError } = useProductPrices(productId, currentPage, PAGE_SIZE)
if (isLoading) { if (isLoading) {
return ( return (
@@ -48,7 +54,9 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
) )
} }
const isEmpty = !prices?.length const total = prices?.total ?? 0
const totalPages = Math.ceil(total / PAGE_SIZE)
const isEmpty = total === 0
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -73,34 +81,62 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
</CanPerform> </CanPerform>
</div> </div>
) : ( ) : (
<div className="rounded-md border"> <>
<Table> <div className="rounded-md border">
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>Desde</TableHead> <TableRow>
<TableHead>Hasta</TableHead> <TableHead>Desde</TableHead>
<TableHead>Precio</TableHead> <TableHead>Hasta</TableHead>
<TableHead>Estado</TableHead> <TableHead>Precio</TableHead>
</TableRow> <TableHead>Estado</TableHead>
</TableHeader>
<TableBody>
{prices.map((p) => (
<TableRow key={p.id}>
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
<TableCell>
{p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'}
</TableCell>
<TableCell>{formatCurrency(p.price)}</TableCell>
<TableCell>
{p.isActive ? (
<Badge variant="default">Vigente</Badge>
) : null}
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {prices?.items.map((p) => (
</div> <TableRow key={p.id}>
<TableCell>{formatCivilDate(p.priceValidFrom)}</TableCell>
<TableCell>
{p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'}
</TableCell>
<TableCell>{formatCurrency(p.price)}</TableCell>
<TableCell>
{p.isActive ? (
<Badge variant="default">Vigente</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-sm text-muted-foreground">
Página {currentPage} de {totalPages || 1}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= (totalPages || 1)}
onClick={() => setCurrentPage((p) => p + 1)}
>
Siguiente
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)} )}
<AddProductPriceDialog <AddProductPriceDialog

View File

@@ -7,7 +7,7 @@ export function useAddProductPrice(productId: number) {
return useMutation({ return useMutation({
mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload), mutationFn: (payload: AddProductPriceRequest) => addProductPrice(productId, payload),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] }) queryClient.invalidateQueries({ queryKey: ['product-prices', productId] })
}, },
}) })
} }

View File

@@ -1,11 +1,12 @@
import { useQuery } from '@tanstack/react-query' import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { getProductPrices } from '../api/getProductPrices' import { getProductPrices } from '../api/getProductPrices'
export function useProductPrices(productId: number) { export function useProductPrices(productId: number, page: number = 1, pageSize: number = 20) {
return useQuery({ return useQuery({
queryKey: ['products', productId, 'prices'], queryKey: ['product-prices', productId, page, pageSize],
queryFn: () => getProductPrices(productId), queryFn: () => getProductPrices(productId, page, pageSize),
enabled: productId > 0, enabled: productId > 0,
staleTime: 30_000, staleTime: 30_000,
placeholderData: keepPreviousData,
}) })
} }

View File

@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react' import React from 'react'
import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory' import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory'
import { useAuthStore } from '../../../stores/authStore' import { useAuthStore } from '../../../stores/authStore'
import type { ProductPrice } from '../../../features/products/types' import type { ProductPrice, PagedResult } from '../../../features/products/types'
const API_URL = 'http://localhost:5000' const API_URL = 'http://localhost:5000'
@@ -53,6 +53,17 @@ const regularUser = {
mustChangePassword: false, mustChangePassword: false,
} }
// ─── PagedResult helpers ──────────────────────────────────────────────────────
function makePagedResult(items: ProductPrice[], opts: { page?: number; pageSize?: number; total?: number } = {}): PagedResult<ProductPrice> {
return {
items,
page: opts.page ?? 1,
pageSize: opts.pageSize ?? 20,
total: opts.total ?? items.length,
}
}
// ─── Server ─────────────────────────────────────────────────────────────────── // ─── Server ───────────────────────────────────────────────────────────────────
const server = setupServer() const server = setupServer()
@@ -86,7 +97,7 @@ describe('ProductPriceHistory — loading state', () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, async () => { http.get(`${API_URL}/api/v1/products/1/prices`, async () => {
await new Promise(() => {}) await new Promise(() => {})
return HttpResponse.json([]) return HttpResponse.json(makePagedResult([]))
}), }),
) )
renderHistory() renderHistory()
@@ -111,9 +122,11 @@ describe('ProductPriceHistory — error state', () => {
}) })
describe('ProductPriceHistory — empty state', () => { describe('ProductPriceHistory — empty state', () => {
it('shows CTA when no prices exist', async () => { it('shows CTA when no prices exist (total=0)', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])), http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json(makePagedResult([], { total: 0 })),
),
) )
renderHistory() renderHistory()
await waitFor(() => await waitFor(() =>
@@ -130,7 +143,7 @@ describe('ProductPriceHistory — data rendering', () => {
it('renders price list with formatted dates and prices', async () => { it('renders price list with formatted dates and prices', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json([mockActivePrice, mockClosedPrice]), HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])),
), ),
) )
renderHistory() renderHistory()
@@ -146,7 +159,7 @@ describe('ProductPriceHistory — data rendering', () => {
it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => { it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json([mockActivePrice, mockClosedPrice]), HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])),
), ),
) )
renderHistory() renderHistory()
@@ -156,7 +169,7 @@ describe('ProductPriceHistory — data rendering', () => {
it('shows formatted currency for prices', async () => { it('shows formatted currency for prices', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json([mockActivePrice]), HttpResponse.json(makePagedResult([mockActivePrice])),
), ),
) )
renderHistory() renderHistory()
@@ -173,7 +186,7 @@ describe('ProductPriceHistory — dialog integration', () => {
it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => { it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json([mockActivePrice]), HttpResponse.json(makePagedResult([mockActivePrice])),
), ),
) )
renderHistory() renderHistory()
@@ -189,7 +202,7 @@ describe('ProductPriceHistory — dialog integration', () => {
it('hides "Programar nuevo precio" button when user lacks permission', async () => { it('hides "Programar nuevo precio" button when user lacks permission', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json([mockActivePrice]), HttpResponse.json(makePagedResult([mockActivePrice])),
), ),
) )
renderHistory(1, regularUser) renderHistory(1, regularUser)
@@ -197,3 +210,111 @@ describe('ProductPriceHistory — dialog integration', () => {
expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument()
}) })
}) })
// ─── §P.9 — §P.12: Pagination controls ───────────────────────────────────────
describe('ProductPriceHistory — pagination controls (§P.9§P.12)', () => {
// §P.9: page 1 of many — Next enabled, Previous disabled
it('§P.9 — page 1: Next enabled and Previous disabled when total > pageSize', async () => {
// 30 total, pageSize=20 → 2 pages
const page1Items = Array.from({ length: 20 }, (_, i) => ({
...mockActivePrice,
id: i + 1,
isActive: i === 0,
}))
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json(makePagedResult(page1Items, { page: 1, pageSize: 20, total: 30 })),
),
)
renderHistory()
await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument())
const prevBtn = screen.getByRole('button', { name: /anterior/i })
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
expect(prevBtn).toBeDisabled()
expect(nextBtn).not.toBeDisabled()
})
// §P.10: click Next → refetch with page=2, renders new items
it('§P.10 — click Next → refetches page=2 and shows new items', async () => {
const price1 = { ...mockActivePrice, id: 1, price: 100, priceValidFrom: '2026-04-01', isActive: true }
const price2 = { ...mockActivePrice, id: 21, price: 200, priceValidFrom: '2026-01-01', priceValidTo: '2026-03-31', isActive: false }
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page') ?? '1'
if (page === '2') {
return HttpResponse.json(makePagedResult([price2], { page: 2, pageSize: 20, total: 21 }))
}
return HttpResponse.json(makePagedResult([price1], { page: 1, pageSize: 20, total: 21 }))
}),
)
renderHistory()
// Wait for page 1 to load — price1 has priceValidFrom 2026-04-01
await waitFor(() => expect(screen.getByText('01/04/2026')).toBeInTheDocument())
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
await userEvent.click(nextBtn)
// Page 2 shows price2 — priceValidFrom 2026-01-01
await waitFor(() => expect(screen.getByText('01/01/2026')).toBeInTheDocument())
})
// §P.11: first page always has Previous disabled
it('§P.11 — primera página: botón Anterior siempre deshabilitado', async () => {
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 1 })),
),
)
renderHistory()
await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument())
expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
})
// §P.12: last page has Next disabled (page * pageSize >= total)
it('§P.12 — última página: botón Siguiente deshabilitado', async () => {
// page=2, pageSize=20, total=21 → 2 pages, page 2 is last
const lastPageItem = { ...mockClosedPrice, id: 21 }
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page') ?? '1'
if (page === '2') {
return HttpResponse.json(makePagedResult([lastPageItem], { page: 2, pageSize: 20, total: 21 }))
}
// Page 1: 20 items
const items = Array.from({ length: 20 }, (_, i) => ({ ...mockClosedPrice, id: i + 1 }))
return HttpResponse.json(makePagedResult(items, { page: 1, pageSize: 20, total: 21 }))
}),
)
renderHistory()
// Navigate to page 2
await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument())
const nextBtn = screen.getByRole('button', { name: /siguiente/i })
expect(nextBtn).not.toBeDisabled()
await userEvent.click(nextBtn)
// On page 2, Next should be disabled
await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeDisabled())
expect(screen.getByRole('button', { name: /anterior/i })).not.toBeDisabled()
})
it('shows page info text', async () => {
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () =>
HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 30 })),
),
)
renderHistory()
await waitFor(() =>
expect(screen.getByText(/página 1 de 2/i)).toBeInTheDocument(),
)
})
})

View File

@@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react' import React from 'react'
import { useProductPrices } from '../../../features/products/hooks/useProductPrices' import { useProductPrices } from '../../../features/products/hooks/useProductPrices'
import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice' import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice'
import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types' import type { ProductPrice, AddProductPriceResponse, PagedResult } from '../../../features/products/types'
const API_URL = 'http://localhost:5000' const API_URL = 'http://localhost:5000'
@@ -23,6 +23,13 @@ const mockPrice: ProductPrice = {
isActive: true, isActive: true,
} }
const mockPagedResult: PagedResult<ProductPrice> = {
items: [mockPrice],
page: 1,
pageSize: 20,
total: 1,
}
const mockResponse: AddProductPriceResponse = { const mockResponse: AddProductPriceResponse = {
created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true }, created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true },
closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false }, closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false },
@@ -46,16 +53,55 @@ function makeWrapper() {
} }
describe('useProductPrices', () => { describe('useProductPrices', () => {
it('fetches prices for productId and returns data', async () => { it('fetches prices for productId and returns PagedResult data', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
) )
const { qc, wrapper } = makeWrapper() const { wrapper } = makeWrapper()
const { result } = renderHook(() => useProductPrices(1), { wrapper }) const { result } = renderHook(() => useProductPrices(1), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([mockPrice]) expect(result.current.data).toEqual(mockPagedResult)
// Verify caching: queryKey should be ['products', 1, 'prices'] expect(result.current.data?.items).toEqual([mockPrice])
expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined() })
it('includes page and pageSize in queryKey for correct caching', async () => {
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
)
const { qc, wrapper } = makeWrapper()
const { result } = renderHook(() => useProductPrices(1, 1, 20), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// queryKey must include page and pageSize
expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined()
})
it('uses different cache entry for different pages', async () => {
const page2Result: PagedResult<ProductPrice> = {
items: [{ ...mockPrice, id: 2 }],
page: 2,
pageSize: 20,
total: 21,
}
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page')
return page === '2' ? HttpResponse.json(page2Result) : HttpResponse.json(mockPagedResult)
}),
)
const { qc, wrapper } = makeWrapper()
const { result: r1 } = renderHook(() => useProductPrices(1, 1, 20), { wrapper })
await waitFor(() => expect(r1.current.isSuccess).toBe(true))
const { result: r2 } = renderHook(() => useProductPrices(1, 2, 20), { wrapper })
await waitFor(() => expect(r2.current.isSuccess).toBe(true))
// Each page is cached separately
expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined()
expect(qc.getQueryState(['product-prices', 1, 2, 20])).toBeDefined()
expect(r1.current.data?.page).toBe(1)
expect(r2.current.data?.page).toBe(2)
}) })
it('is disabled when productId is 0', async () => { it('is disabled when productId is 0', async () => {
@@ -66,12 +112,27 @@ describe('useProductPrices', () => {
expect(result.current.isFetching).toBe(false) expect(result.current.isFetching).toBe(false)
expect(result.current.data).toBeUndefined() expect(result.current.data).toBeUndefined()
}) })
it('sends page and pageSize as query params in the URL', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json(mockPagedResult)
}),
)
const { wrapper } = makeWrapper()
const { result } = renderHook(() => useProductPrices(1, 2, 10), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('page=2')
expect(capturedUrl).toContain('pageSize=10')
})
}) })
describe('useAddProductPrice', () => { describe('useAddProductPrice', () => {
it('calls POST and invalidates product prices queries on success', async () => { it('calls POST and invalidates product prices queries on success', async () => {
server.use( server.use(
http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)),
http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => http.post(`${API_URL}/api/v1/admin/products/1/prices`, () =>
HttpResponse.json(mockResponse, { status: 201 }), HttpResponse.json(mockResponse, { status: 201 }),
), ),
@@ -85,7 +146,7 @@ describe('useAddProductPrice', () => {
result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' }) result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' })
}) })
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] }) expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-prices', 1] })
}) })
it('returns error state on 409', async () => { it('returns error state on 409', async () => {