feat: replacement
This commit is contained in:
224
src/app/[locale]/admin/replacements/page.tsx
Normal file
224
src/app/[locale]/admin/replacements/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user