220 lines
7.7 KiB
TypeScript
220 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|