225 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|