Files
boka-gasol247/src/app/[locale]/admin/replacements/page.tsx
2026-05-22 20:33:21 +02:00

225 lines
8.3 KiB
TypeScript

import { setRequestLocale, getTranslations } from 'next-intl/server';
import { revalidatePath } from 'next/cache';
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';
export const dynamic = 'force-dynamic';
const STATUSES = ['REQUESTED', 'SCHEDULED', 'DELIVERED', 'CANCELLED'] as const;
type ReplacementStatus = (typeof STATUSES)[number];
async function setReplacementStatus(formData: FormData) {
'use server';
await requireAdmin();
const id = String(formData.get('id') ?? '');
const status = String(formData.get('status') ?? '');
if (!id || !STATUSES.includes(status as ReplacementStatus)) return;
await prisma.replacementRequest.update({
where: { id },
data: { status },
});
revalidatePath('/admin/replacements');
// Find bookingId to refresh detail view too.
const rr = await prisma.replacementRequest.findUnique({
where: { id },
select: { bookingId: true },
});
if (rr) revalidatePath(`/admin/bookings/${rr.bookingId}`);
}
export default async function AdminReplacementsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ status?: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
await requireAdmin();
const sp = await searchParams;
const loc = locale as 'sv' | 'en';
const t = await getTranslations('adminReplacements');
const where: Prisma.ReplacementRequestWhereInput =
sp.status && STATUSES.includes(sp.status as ReplacementStatus)
? { status: sp.status }
: {};
const rows = await prisma.replacementRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
include: { booking: true, bookingItem: true, pickupSlot: true },
take: 200,
});
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
<div className="card p-3">
<nav className="flex flex-wrap gap-2 text-sm">
<Link
href="/admin/replacements"
className={`rounded-md px-3 py-1 ${!sp.status ? 'bg-ink-900 text-white' : 'bg-ink-100 text-ink-700 hover:bg-ink-200'}`}
>
{t('filters.all')}
</Link>
{STATUSES.map((s) => (
<Link
key={s}
href={`/admin/replacements?status=${s}`}
className={`rounded-md px-3 py-1 ${sp.status === s ? 'bg-ink-900 text-white' : 'bg-ink-100 text-ink-700 hover:bg-ink-200'}`}
>
{t(`status.${s}`)}
</Link>
))}
</nav>
</div>
<div className="card overflow-hidden">
{rows.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">{t('columns.created')}</th>
<th className="px-4 py-2.5">{t('columns.booking')}</th>
<th className="px-4 py-2.5">{t('columns.org')}</th>
<th className="px-4 py-2.5">{t('columns.item')}</th>
<th className="px-4 py-2.5 text-right">{t('columns.quantity')}</th>
<th className="px-4 py-2.5 text-right">{t('columns.total')}</th>
<th className="px-4 py-2.5">{t('columns.pickup')}</th>
<th className="px-4 py-2.5">{t('columns.status')}</th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{rows.map((r) => (
<tr key={r.id} className="hover:bg-ink-50/50">
<td className="px-4 py-2.5 text-ink-600">
{r.createdAt.toLocaleString(
loc === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'short', timeStyle: 'short' },
)}
</td>
<td className="px-4 py-2.5">
<Link
href={`/admin/bookings/${r.bookingId}`}
className="font-mono text-xs text-brand-600 hover:underline"
>
{r.booking.bookingNumber}
</Link>
</td>
<td className="px-4 py-2.5 text-ink-900">
{r.booking.orgName}
</td>
<td className="px-4 py-2.5 text-ink-900">
{loc === 'sv' ? r.nameSv : r.nameEn}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{r.quantity}
</td>
<td className="px-4 py-2.5 text-right tabular-nums">
{formatOre(r.lineTotalOre, loc)}
</td>
<td className="px-4 py-2.5 text-ink-900">
{r.pickupSlot ? (
<>
<div>
{loc === 'sv'
? r.pickupSlot.labelSv
: r.pickupSlot.labelEn}
</div>
<div className="text-xs text-ink-500">
{r.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">
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs text-ink-700">
{t(`status.${r.status as ReplacementStatus}`)}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{r.status === 'REQUESTED' && (
<StatusAction
id={r.id}
target="SCHEDULED"
label={t('actions.markScheduled')}
action={setReplacementStatus}
/>
)}
{(r.status === 'REQUESTED' ||
r.status === 'SCHEDULED') && (
<StatusAction
id={r.id}
target="DELIVERED"
label={t('actions.markDelivered')}
action={setReplacementStatus}
/>
)}
{r.status !== 'CANCELLED' &&
r.status !== 'DELIVERED' && (
<StatusAction
id={r.id}
target="CANCELLED"
label={t('actions.markCancelled')}
action={setReplacementStatus}
destructive
/>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
function StatusAction({
id,
target,
label,
action,
destructive,
}: {
id: string;
target: ReplacementStatus;
label: string;
action: (fd: FormData) => Promise<void>;
destructive?: boolean;
}) {
return (
<form action={action}>
<input type="hidden" name="id" value={id} />
<input type="hidden" name="status" value={target} />
<button
type="submit"
className={`rounded-md px-2 py-1 text-xs ${destructive ? 'border border-red-200 bg-white text-red-700 hover:bg-red-50' : 'bg-ink-100 text-ink-700 hover:bg-ink-200'}`}
>
{label}
</button>
</form>
);
}