Skip to content

Commit e8c5349

Browse files
(SP: 1) [Admin] Add pagination to shop admin listings (URL-synced page param + page size + server query)
1 parent 8278c1b commit e8c5349

3 files changed

Lines changed: 148 additions & 111 deletions

File tree

frontend/app/[locale]/shop/admin/orders/page.tsx

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ import {
88
} from '@/lib/shop/currency';
99
import { fromDbMoney } from '@/lib/shop/money';
1010
import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar';
11+
import { AdminPagination } from '@/components/shop/admin/admin-pagination';
1112
import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page';
1213

1314
export const dynamic = 'force-dynamic';
1415

16+
const PAGE_SIZE = 50;
17+
18+
function parsePage(input: string | undefined): number {
19+
const n = Number.parseInt(input ?? '1', 10);
20+
return Number.isFinite(n) && n > 0 ? n : 1;
21+
}
22+
1523
function pickMinor(minor: unknown, legacyMajor: unknown): number | null {
1624
if (typeof minor === 'number') return minor;
1725
if (legacyMajor === null || legacyMajor === undefined) return null;
@@ -29,12 +37,26 @@ function formatDate(value: Date | null | undefined) {
2937

3038
export default async function AdminOrdersPage({
3139
params,
40+
searchParams,
3241
}: {
3342
params: Promise<{ locale: string }>;
43+
searchParams: Promise<{ page?: string }>;
3444
}) {
3545
await guardShopAdminPage();
3646
const { locale } = await params;
37-
const { items } = await getAdminOrdersPage({ limit: 50, offset: 0 });
47+
const sp = await searchParams;
48+
49+
const page = parsePage(sp.page);
50+
const offset = (page - 1) * PAGE_SIZE;
51+
52+
// overfetch for hasNext without COUNT
53+
const { items: all } = await getAdminOrdersPage({
54+
limit: PAGE_SIZE + 1,
55+
offset,
56+
});
57+
58+
const hasNext = all.length > PAGE_SIZE;
59+
const items = all.slice(0, PAGE_SIZE);
3860

3961
return (
4062
<>
@@ -57,27 +79,13 @@ export default async function AdminOrdersPage({
5779
<table className="min-w-full divide-y divide-border text-sm">
5880
<thead className="bg-muted/50">
5981
<tr>
60-
<th className="px-3 py-2 text-left font-semibold text-foreground">
61-
Created
62-
</th>
63-
<th className="px-3 py-2 text-left font-semibold text-foreground">
64-
Status
65-
</th>
66-
<th className="px-3 py-2 text-left font-semibold text-foreground">
67-
Total
68-
</th>
69-
<th className="px-3 py-2 text-left font-semibold text-foreground">
70-
Items
71-
</th>
72-
<th className="px-3 py-2 text-left font-semibold text-foreground">
73-
Provider
74-
</th>
75-
<th className="px-3 py-2 text-left font-semibold text-foreground">
76-
Order ID
77-
</th>
78-
<th className="px-3 py-2 text-left font-semibold text-foreground">
79-
Actions
80-
</th>
82+
<th className="px-3 py-2 text-left font-semibold text-foreground">Created</th>
83+
<th className="px-3 py-2 text-left font-semibold text-foreground">Status</th>
84+
<th className="px-3 py-2 text-left font-semibold text-foreground">Total</th>
85+
<th className="px-3 py-2 text-left font-semibold text-foreground">Items</th>
86+
<th className="px-3 py-2 text-left font-semibold text-foreground">Provider</th>
87+
<th className="px-3 py-2 text-left font-semibold text-foreground">Order ID</th>
88+
<th className="px-3 py-2 text-left font-semibold text-foreground">Actions</th>
8189
</tr>
8290
</thead>
8391

@@ -97,26 +105,15 @@ export default async function AdminOrdersPage({
97105
<td className="px-3 py-2 text-foreground">
98106
{(() => {
99107
const c = orderCurrency(order, locale);
100-
const totalMinor = pickMinor(
101-
order?.totalAmountMinor,
102-
order?.totalAmount
103-
);
104-
return totalMinor === null
105-
? '-'
106-
: formatMoney(totalMinor, c, locale);
108+
const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount);
109+
return totalMinor === null ? '-' : formatMoney(totalMinor, c, locale);
107110
})()}
108111
</td>
109112

110-
<td className="px-3 py-2 text-muted-foreground">
111-
{order.itemCount}
112-
</td>
113-
<td className="px-3 py-2 text-muted-foreground">
114-
{order.paymentProvider}
115-
</td>
113+
<td className="px-3 py-2 text-muted-foreground">{order.itemCount}</td>
114+
<td className="px-3 py-2 text-muted-foreground">{order.paymentProvider}</td>
116115

117-
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
118-
{order.id}
119-
</td>
116+
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{order.id}</td>
120117

121118
<td className="px-3 py-2">
122119
<Link
@@ -138,6 +135,12 @@ export default async function AdminOrdersPage({
138135
) : null}
139136
</tbody>
140137
</table>
138+
139+
<AdminPagination
140+
basePath="/shop/admin/orders"
141+
page={page}
142+
hasNext={hasNext}
143+
/>
141144
</div>
142145
</div>
143146
</>

frontend/app/[locale]/shop/admin/products/page.tsx

Lines changed: 50 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar';
44
import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page';
55

66
import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle';
7+
import { AdminPagination } from '@/components/shop/admin/admin-pagination';
78
import { db } from '@/db';
89
import { products, productPrices } from '@/db/schema';
910
import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency';
1011
import { fromDbMoney } from '@/lib/shop/money';
1112
import { logWarn } from '@/lib/logging';
1213

14+
const PAGE_SIZE = 25;
15+
16+
function parsePage(input: string | undefined): number {
17+
const n = Number.parseInt(input ?? '1', 10);
18+
return Number.isFinite(n) && n > 0 ? n : 1;
19+
}
20+
1321
function formatDate(value: Date | null, locale: string) {
1422
if (!value) return '-';
1523
return value.toLocaleDateString(locale);
@@ -19,7 +27,6 @@ function safeFromDbMoney(
1927
value: unknown,
2028
ctx: { productId: string; currency: string }
2129
): number | null {
22-
// expected case for leftJoin: missing price row
2330
if (value == null) return null;
2431

2532
try {
@@ -37,16 +44,21 @@ function safeFromDbMoney(
3744

3845
export default async function AdminProductsPage({
3946
params,
47+
searchParams,
4048
}: {
4149
params: Promise<{ locale: string }>;
50+
searchParams: Promise<{ page?: string }>;
4251
}) {
4352
await guardShopAdminPage();
4453
const { locale } = await params;
54+
const sp = await searchParams;
55+
56+
const page = parsePage(sp.page);
57+
const offset = (page - 1) * PAGE_SIZE;
4558

46-
// currency policy: derived from locale
4759
const displayCurrency = resolveCurrencyFromLocale(locale);
4860

49-
const rows = await db
61+
const all = await db
5062
.select({
5163
id: products.id,
5264
title: products.title,
@@ -58,7 +70,7 @@ export default async function AdminProductsPage({
5870
isActive: products.isActive,
5971
isFeatured: products.isFeatured,
6072
createdAt: products.createdAt,
61-
price: productPrices.price, // numeric (major) from product_prices
73+
price: productPrices.price,
6274
})
6375
.from(products)
6476
.leftJoin(
@@ -68,16 +80,20 @@ export default async function AdminProductsPage({
6880
eq(productPrices.currency, displayCurrency)
6981
)
7082
)
71-
.orderBy(desc(products.createdAt));
83+
// стабільне сортування (tie-breaker)
84+
.orderBy(desc(products.createdAt), desc(products.id))
85+
.limit(PAGE_SIZE + 1)
86+
.offset(offset);
87+
88+
const hasNext = all.length > PAGE_SIZE;
89+
const rows = all.slice(0, PAGE_SIZE);
7290

7391
return (
7492
<>
7593
<ShopAdminTopbar />
7694
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
7795
<div className="flex items-center justify-between">
78-
<h1 className="text-2xl font-bold text-foreground">
79-
Admin · Products
80-
</h1>
96+
<h1 className="text-2xl font-bold text-foreground">Admin · Products</h1>
8197
<Link
8298
href="/shop/admin/products/new"
8399
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
@@ -90,39 +106,17 @@ export default async function AdminProductsPage({
90106
<table className="w-full table-fixed divide-y divide-border text-sm">
91107
<thead className="bg-muted/50">
92108
<tr>
93-
<th className="w-[20%] px-3 py-2 text-left font-semibold text-foreground">
94-
Title
95-
</th>
96-
<th className="w-[18%] px-3 py-2 text-left font-semibold text-foreground">
97-
Slug
98-
</th>
99-
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">
100-
Price
101-
</th>
102-
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">
103-
Category
104-
</th>
105-
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">
106-
Type
107-
</th>
108-
<th className="w-[5%] px-3 py-2 text-left font-semibold text-foreground">
109-
Stock
110-
</th>
111-
<th className="w-[5%] px-3 py-2 text-left font-semibold text-foreground">
112-
Badge
113-
</th>
114-
<th className="w-[5%] px-3 py-2 text-left font-semibold text-foreground">
115-
Active
116-
</th>
117-
<th className="w-[6%] px-3 py-2 text-left font-semibold text-foreground">
118-
Featured
119-
</th>
120-
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">
121-
Created
122-
</th>
123-
<th className="w-[9%] px-3 py-2 text-left font-semibold text-foreground">
124-
Actions
125-
</th>
109+
<th className="w-[20%] px-3 py-2 text-left font-semibold text-foreground">Title</th>
110+
<th className="w-[18%] px-3 py-2 text-left font-semibold text-foreground">Slug</th>
111+
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">Price</th>
112+
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">Category</th>
113+
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">Type</th>
114+
<th className="w-[5%] px-3 py-2 text-left font-semibold text-foreground">Stock</th>
115+
<th className="w-[5%] px-3 py-2 text-left font-semibold text-foreground">Badge</th>
116+
<th className="w-[5%] px-3 py-2 text-left font-semibold text-foreground">Active</th>
117+
<th className="w-[6%] px-3 py-2 text-left font-semibold text-foreground">Featured</th>
118+
<th className="w-[8%] px-3 py-2 text-left font-semibold text-foreground">Created</th>
119+
<th className="w-[9%] px-3 py-2 text-left font-semibold text-foreground">Actions</th>
126120
</tr>
127121
</thead>
128122

@@ -136,59 +130,39 @@ export default async function AdminProductsPage({
136130
return (
137131
<tr key={row.id} className="hover:bg-muted/50">
138132
<td className="px-3 py-2 font-medium text-foreground max-w-0">
139-
<div className="truncate" title={row.title}>
140-
{row.title}
141-
</div>
133+
<div className="truncate" title={row.title}>{row.title}</div>
142134
</td>
143135

144136
<td className="px-3 py-2 text-muted-foreground max-w-0">
145-
<div className="truncate" title={row.slug}>
146-
{row.slug}
147-
</div>
137+
<div className="truncate" title={row.slug}>{row.slug}</div>
148138
</td>
149139

150140
<td className="px-3 py-2 text-foreground whitespace-nowrap">
151-
{priceMinor === null
152-
? '-'
153-
: formatMoney(priceMinor, displayCurrency, locale)}
141+
{priceMinor === null ? '-' : formatMoney(priceMinor, displayCurrency, locale)}
154142
</td>
155143

156144
<td className="px-3 py-2 text-muted-foreground max-w-0">
157-
<div className="truncate" title={row.category ?? '-'}>
158-
{row.category ?? '-'}
159-
</div>
145+
<div className="truncate" title={row.category ?? '-'}>{row.category ?? '-'}</div>
160146
</td>
161147

162148
<td className="px-3 py-2 text-muted-foreground max-w-0">
163-
<div className="truncate" title={row.type ?? '-'}>
164-
{row.type ?? '-'}
165-
</div>
149+
<div className="truncate" title={row.type ?? '-'}>{row.type ?? '-'}</div>
166150
</td>
167151

168-
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
169-
{row.stock}
170-
</td>
152+
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{row.stock}</td>
171153

172154
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
173155
{row.badge === 'NONE' ? '-' : row.badge}
174156
</td>
175157

176158
<td className="px-3 py-2 whitespace-nowrap">
177-
<span
178-
className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground"
179-
aria-label={row.isActive ? 'Active' : 'Inactive'}
180-
>
159+
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
181160
{row.isActive ? 'Yes' : 'No'}
182161
</span>
183162
</td>
184163

185164
<td className="px-3 py-2 whitespace-nowrap">
186-
<span
187-
className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground"
188-
aria-label={
189-
row.isFeatured ? 'Featured' : 'Not featured'
190-
}
191-
>
165+
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
192166
{row.isFeatured ? 'Yes' : 'No'}
193167
</span>
194168
</td>
@@ -211,17 +185,20 @@ export default async function AdminProductsPage({
211185
>
212186
Edit
213187
</Link>
214-
<AdminProductStatusToggle
215-
id={row.id}
216-
initialIsActive={row.isActive}
217-
/>
188+
<AdminProductStatusToggle id={row.id} initialIsActive={row.isActive} />
218189
</div>
219190
</td>
220191
</tr>
221192
);
222193
})}
223194
</tbody>
224195
</table>
196+
197+
<AdminPagination
198+
basePath="/shop/admin/products"
199+
page={page}
200+
hasNext={hasNext}
201+
/>
225202
</div>
226203
</div>
227204
</>

0 commit comments

Comments
 (0)