initial booking
This commit is contained in:
219
src/app/[locale]/admin/page.tsx
Normal file
219
src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { formatOre } from '@/lib/money';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
import { AdminFilters } from '@/components/admin/AdminFilters';
|
||||
import { SortHeader } from '@/components/admin/SortHeader';
|
||||
import { Pagination } from '@/components/admin/Pagination';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const SORT_WHITELIST: Record<string, Prisma.BookingOrderByWithRelationInput> = {
|
||||
createdAt: { createdAt: 'desc' },
|
||||
bookingNumber: { bookingNumber: 'desc' },
|
||||
orgName: { orgName: 'desc' },
|
||||
contactName: { contactName: 'desc' },
|
||||
totalOre: { totalOre: 'desc' },
|
||||
status: { status: 'desc' },
|
||||
pickupSlot: { pickupSlot: { startsAt: 'desc' } },
|
||||
};
|
||||
|
||||
function buildOrderBy(
|
||||
sort: string,
|
||||
dir: 'asc' | 'desc',
|
||||
): Prisma.BookingOrderByWithRelationInput {
|
||||
const base = SORT_WHITELIST[sort] ?? SORT_WHITELIST.createdAt;
|
||||
// Replace direction
|
||||
if ('pickupSlot' in base) {
|
||||
return { pickupSlot: { startsAt: dir } };
|
||||
}
|
||||
const [key] = Object.keys(base);
|
||||
return { [key]: dir } as Prisma.BookingOrderByWithRelationInput;
|
||||
}
|
||||
|
||||
export default async function AdminBookingsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
status?: string;
|
||||
page?: string;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.bookings');
|
||||
const sp = await searchParams;
|
||||
|
||||
const loc = locale as 'sv' | 'en';
|
||||
const page = Math.max(1, parseInt(sp.page ?? '1', 10) || 1);
|
||||
const sort = sp.sort && SORT_WHITELIST[sp.sort] ? sp.sort : 'createdAt';
|
||||
const dir: 'asc' | 'desc' = sp.dir === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const where: Prisma.BookingWhereInput = {
|
||||
...(sp.status && sp.status !== 'all' ? { status: sp.status } : {}),
|
||||
...(sp.q
|
||||
? {
|
||||
OR: [
|
||||
{ bookingNumber: { contains: sp.q } },
|
||||
{ orgName: { contains: sp.q } },
|
||||
{ email: { contains: sp.q } },
|
||||
{ contactName: { contains: sp.q } },
|
||||
{ orgNumber: { contains: sp.q } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, bookings] = await Promise.all([
|
||||
prisma.booking.count({ where }),
|
||||
prisma.booking.findMany({
|
||||
where,
|
||||
orderBy: buildOrderBy(sort, dir),
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
take: PAGE_SIZE,
|
||||
include: { pickupSlot: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Build export URL preserving current filters (skip page/sort).
|
||||
const exportParams = new URLSearchParams();
|
||||
if (sp.status) exportParams.set('status', sp.status);
|
||||
if (sp.q) exportParams.set('q', sp.q);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||
<a
|
||||
href={`/api/admin/export?${exportParams.toString()}`}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
↓ {t('export')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<AdminFilters />
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{bookings.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-ink-500">{t('empty')}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-ink-50 text-left">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">
|
||||
<SortHeader
|
||||
field="bookingNumber"
|
||||
label={t('columns.number')}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2.5">
|
||||
<SortHeader field="createdAt" label={t('columns.date')} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5">
|
||||
<SortHeader field="orgName" label={t('columns.org')} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5">
|
||||
<SortHeader
|
||||
field="contactName"
|
||||
label={t('columns.contact')}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2.5">
|
||||
<SortHeader
|
||||
field="pickupSlot"
|
||||
label={t('detail.pickup')}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
<SortHeader
|
||||
field="totalOre"
|
||||
label={t('columns.total')}
|
||||
align="right"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-2.5">
|
||||
<SortHeader field="status" label={t('columns.status')} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-ink-50/50">
|
||||
<td className="px-4 py-2.5 font-mono text-xs">
|
||||
{b.bookingNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">
|
||||
{b.createdAt.toLocaleDateString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-ink-900">
|
||||
{b.orgName}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">{b.orgNumber}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div>{b.contactName}</div>
|
||||
<div className="text-xs text-ink-500">{b.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{b.pickupSlot ? (
|
||||
<>
|
||||
<div className="text-ink-900">
|
||||
{loc === 'sv'
|
||||
? b.pickupSlot.labelSv
|
||||
: b.pickupSlot.labelEn}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">
|
||||
{b.pickupSlot.startsAt.toLocaleString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'short', timeStyle: 'short' },
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
{formatOre(b.totalOre, loc)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusBadge status={b.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Link
|
||||
href={`/admin/bookings/${b.id}`}
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
{t('view')} →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={total} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user