initial booking
This commit is contained in:
92
src/app/[locale]/admin/bookings/[id]/actions.ts
Normal file
92
src/app/[locale]/admin/bookings/[id]/actions.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import {
|
||||
clampFulfillment,
|
||||
deriveBookingStatus,
|
||||
} from '@/lib/bookingStatus';
|
||||
|
||||
async function recomputeBookingStatus(bookingId: string) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!booking) return;
|
||||
const next = deriveBookingStatus(booking.status, booking.items);
|
||||
if (next !== booking.status) {
|
||||
await prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: next },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function setItemFulfillment(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const bookingId = String(formData.get('bookingId') ?? '');
|
||||
const itemId = String(formData.get('itemId') ?? '');
|
||||
const deliveredRaw = Number(formData.get('delivered') ?? 0);
|
||||
const returnedRaw = Number(formData.get('returned') ?? 0);
|
||||
if (!bookingId || !itemId) return;
|
||||
|
||||
const item = await prisma.bookingItem.findUnique({ where: { id: itemId } });
|
||||
if (!item || item.bookingId !== bookingId) return;
|
||||
|
||||
const { delivered, returned } = clampFulfillment(
|
||||
item.quantity,
|
||||
deliveredRaw,
|
||||
returnedRaw,
|
||||
);
|
||||
|
||||
await prisma.bookingItem.update({
|
||||
where: { id: itemId },
|
||||
data: { deliveredQuantity: delivered, returnedQuantity: returned },
|
||||
});
|
||||
|
||||
await recomputeBookingStatus(bookingId);
|
||||
revalidatePath(`/admin/bookings/${bookingId}`);
|
||||
revalidatePath('/admin');
|
||||
}
|
||||
|
||||
export async function markAllDelivered(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const bookingId = String(formData.get('bookingId') ?? '');
|
||||
if (!bookingId) return;
|
||||
|
||||
const items = await prisma.bookingItem.findMany({ where: { bookingId } });
|
||||
await prisma.$transaction(
|
||||
items.map((it) =>
|
||||
prisma.bookingItem.update({
|
||||
where: { id: it.id },
|
||||
data: { deliveredQuantity: it.quantity },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await recomputeBookingStatus(bookingId);
|
||||
revalidatePath(`/admin/bookings/${bookingId}`);
|
||||
revalidatePath('/admin');
|
||||
}
|
||||
|
||||
export async function markAllReturned(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const bookingId = String(formData.get('bookingId') ?? '');
|
||||
if (!bookingId) return;
|
||||
|
||||
const items = await prisma.bookingItem.findMany({ where: { bookingId } });
|
||||
// "Return all" means: everything that was handed out comes back. If nothing
|
||||
// was marked delivered we treat it as a full handout + return cycle.
|
||||
await prisma.$transaction(
|
||||
items.map((it) => {
|
||||
const delivered = it.deliveredQuantity > 0 ? it.deliveredQuantity : it.quantity;
|
||||
return prisma.bookingItem.update({
|
||||
where: { id: it.id },
|
||||
data: { deliveredQuantity: delivered, returnedQuantity: delivered },
|
||||
});
|
||||
}),
|
||||
);
|
||||
await recomputeBookingStatus(bookingId);
|
||||
revalidatePath(`/admin/bookings/${bookingId}`);
|
||||
revalidatePath('/admin');
|
||||
}
|
||||
288
src/app/[locale]/admin/bookings/[id]/page.tsx
Normal file
288
src/app/[locale]/admin/bookings/[id]/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
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 { FulfillmentTable } from '@/components/admin/FulfillmentTable';
|
||||
import { sendBookingConfirmation } from '@/lib/mailjet';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function setStatus(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
const status = String(formData.get('status') ?? '');
|
||||
if (!id || !status) return;
|
||||
await prisma.booking.update({ where: { id }, data: { status } });
|
||||
revalidatePath(`/admin/bookings/${id}`);
|
||||
revalidatePath('/admin');
|
||||
}
|
||||
|
||||
async function resendEmail(formData: FormData) {
|
||||
'use server';
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: { items: true, pickupSlot: true },
|
||||
});
|
||||
if (booking) {
|
||||
await sendBookingConfirmation(booking);
|
||||
}
|
||||
revalidatePath(`/admin/bookings/${id}`);
|
||||
}
|
||||
|
||||
export default async function BookingDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
|
||||
const t = await getTranslations('admin.bookings');
|
||||
const c = await getTranslations('common');
|
||||
const loc = locale as 'sv' | 'en';
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { id },
|
||||
include: { items: true, pickupSlot: true },
|
||||
});
|
||||
if (!booking) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">
|
||||
{t('detail.title', { number: booking.bookingNumber })}
|
||||
</h1>
|
||||
<StatusBadge status={booking.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="card p-4 lg:col-span-2">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{t('detail.items')}
|
||||
</h2>
|
||||
<table className="mt-2 w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-ink-500">
|
||||
<th className="py-1">SKU</th>
|
||||
<th className="py-1">{loc === 'sv' ? 'Namn' : 'Name'}</th>
|
||||
<th className="py-1 text-right">{c('quantity')}</th>
|
||||
<th className="py-1 text-right">{c('price')}</th>
|
||||
<th className="py-1 text-right">{c('total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{booking.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td className="py-2 font-mono text-xs">{it.sku}</td>
|
||||
<td className="py-2">
|
||||
{loc === 'sv' ? it.nameSv : it.nameEn}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{it.quantity}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatOre(it.unitPriceOre, loc)}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatOre(it.lineTotalOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t border-ink-200">
|
||||
<tr>
|
||||
<td colSpan={4} className="pt-2 text-ink-600">
|
||||
{c('subtotal')}
|
||||
</td>
|
||||
<td className="pt-2 text-right tabular-nums">
|
||||
{formatOre(booking.subtotalOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4} className="text-ink-500">
|
||||
{c('ofWhichVat')}
|
||||
</td>
|
||||
<td className="text-right tabular-nums text-ink-500">
|
||||
{formatOre(booking.vatOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4} className="pt-1 font-semibold">
|
||||
{c('total')}
|
||||
</td>
|
||||
<td className="pt-1 text-right font-semibold tabular-nums">
|
||||
{formatOre(booking.totalOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{booking.notes && (
|
||||
<div className="mt-4 rounded-lg bg-ink-50 p-3 text-sm">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
Notes
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap text-ink-700">
|
||||
{booking.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<FulfillmentTable
|
||||
bookingId={booking.id}
|
||||
items={booking.items.map((it) => ({
|
||||
id: it.id,
|
||||
sku: it.sku,
|
||||
nameSv: it.nameSv,
|
||||
nameEn: it.nameEn,
|
||||
quantity: it.quantity,
|
||||
deliveredQuantity: it.deliveredQuantity,
|
||||
returnedQuantity: it.returnedQuantity,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{t('detail.customer')}
|
||||
</h2>
|
||||
<dl className="mt-2 space-y-1 text-sm">
|
||||
<DLRow label={loc === 'sv' ? 'Organisation' : 'Organization'} value={booking.orgName} />
|
||||
<DLRow label="Org.nr" value={booking.orgNumber} />
|
||||
<DLRow label={loc === 'sv' ? 'Kontakt' : 'Contact'} value={booking.contactName} />
|
||||
<DLRow
|
||||
label="E-post"
|
||||
value={
|
||||
<a className="text-brand-600 hover:underline" href={`mailto:${booking.email}`}>
|
||||
{booking.email}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<DLRow label={loc === 'sv' ? 'Telefon' : 'Phone'} value={booking.phone} />
|
||||
<DLRow
|
||||
label={loc === 'sv' ? 'Adress' : 'Address'}
|
||||
value={
|
||||
<span>
|
||||
{booking.address}
|
||||
<br />
|
||||
{booking.postalCode} {booking.city}
|
||||
<br />
|
||||
{booking.country}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{booking.pickupSlot && (
|
||||
<div className="card p-4">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{t('detail.pickup')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm">
|
||||
{loc === 'sv'
|
||||
? booking.pickupSlot.labelSv
|
||||
: booking.pickupSlot.labelEn}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
{booking.pickupSlot.startsAt.toLocaleString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
Actions
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
{loc === 'sv'
|
||||
? 'Status uppdateras automatiskt från utlämningsraderna.'
|
||||
: 'Status updates automatically from the handout rows.'}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{booking.status !== 'INVOICED' &&
|
||||
booking.status !== 'CANCELLED' && (
|
||||
<StatusButton
|
||||
bookingId={booking.id}
|
||||
status="INVOICED"
|
||||
label={t('detail.markInvoiced')}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
{booking.status !== 'CANCELLED' && (
|
||||
<StatusButton
|
||||
bookingId={booking.id}
|
||||
status="CANCELLED"
|
||||
label={t('detail.markCancelled')}
|
||||
setStatus={setStatus}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
<form action={resendEmail}>
|
||||
<input type="hidden" name="id" value={booking.id} />
|
||||
<button className="btn-secondary w-full text-sm" type="submit">
|
||||
{t('detail.resendEmail')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusButton({
|
||||
bookingId,
|
||||
status,
|
||||
label,
|
||||
setStatus,
|
||||
destructive,
|
||||
}: {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
label: string;
|
||||
setStatus: (fd: FormData) => Promise<void>;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form action={setStatus}>
|
||||
<input type="hidden" name="id" value={bookingId} />
|
||||
<input type="hidden" name="status" value={status} />
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full text-sm ${destructive ? 'btn border border-red-200 bg-white text-red-700 hover:bg-red-50' : 'btn-secondary'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DLRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-ink-500">{label}</dt>
|
||||
<dd className="text-right text-ink-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/app/[locale]/admin/layout.tsx
Normal file
90
src/app/[locale]/admin/layout.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { signOut } from '@/auth';
|
||||
import { getSafeSession } from '@/lib/safeAuth';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await getSafeSession();
|
||||
const t = await getTranslations('admin');
|
||||
|
||||
// Public login page is handled in admin/login/page.tsx — but layout still wraps it.
|
||||
// For non-login admin routes we redirect when not signed in via a route segment guard.
|
||||
// Here we expose `session` to the rendered children via a server util; simpler:
|
||||
// we redirect from this layout only when path is NOT /admin/login. Since segment
|
||||
// info isn't easily accessible, we let each page check itself. Login page will not redirect.
|
||||
// We do the protection by rendering the nav only when signed in; pages must call requireAdmin().
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink-50">
|
||||
<header className="border-b border-ink-200 bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin" className="text-base font-semibold text-ink-900">
|
||||
{t('title')}
|
||||
</Link>
|
||||
{session?.user && (
|
||||
<nav className="hidden gap-1 sm:flex">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
{t('nav.bookings')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
{t('nav.products')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/pickup-slots"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
{t('nav.pickupSlots')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
|
||||
>
|
||||
{t('nav.users')}
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
{session?.user && (
|
||||
<form
|
||||
action={async () => {
|
||||
'use server';
|
||||
await signOut({ redirectTo: '/admin/login' });
|
||||
}}
|
||||
>
|
||||
<button type="submit" className="btn-ghost text-xs">
|
||||
{t('nav.signOut')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
98
src/app/[locale]/admin/login/page.tsx
Normal file
98
src/app/[locale]/admin/login/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { signIn } from '@/auth';
|
||||
import { getSafeSession } from '@/lib/safeAuth';
|
||||
import { Header } from '@/components/Header';
|
||||
|
||||
export default async function LoginPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ error?: string; from?: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const { error, from } = await searchParams;
|
||||
|
||||
const session = await getSafeSession();
|
||||
if (session?.user) {
|
||||
redirect(`/${locale === 'sv' ? '' : locale + '/'}admin`);
|
||||
}
|
||||
|
||||
const t = await getTranslations('admin.login');
|
||||
|
||||
async function login(formData: FormData) {
|
||||
'use server';
|
||||
const email = String(formData.get('email') ?? '');
|
||||
const password = String(formData.get('password') ?? '');
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirectTo: from || '/admin',
|
||||
});
|
||||
} catch (e) {
|
||||
// Auth.js v5 throws an error to trigger the redirect; rethrow non-redirect ones.
|
||||
// Errors that are not "NEXT_REDIRECT" should be re-thrown so Next.js handles routing.
|
||||
if (
|
||||
e &&
|
||||
typeof e === 'object' &&
|
||||
'digest' in e &&
|
||||
typeof (e as { digest?: unknown }).digest === 'string' &&
|
||||
((e as { digest: string }).digest.startsWith('NEXT_REDIRECT') ||
|
||||
(e as { digest: string }).digest.startsWith('NEXT_HTTP_ERROR'))
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
redirect(`/admin/login?error=1`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink-50">
|
||||
<Header />
|
||||
<main className="mx-auto flex max-w-md flex-col px-4 py-12">
|
||||
<div className="card p-6">
|
||||
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||
<form action={login} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="label" htmlFor="email">
|
||||
{t('email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="input mt-1"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="password">
|
||||
{t('password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="input mt-1"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-2 text-sm text-red-700">
|
||||
{t('error')}
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="btn-primary w-full">
|
||||
{t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
94
src/app/[locale]/admin/pickup-slots/[id]/page.tsx
Normal file
94
src/app/[locale]/admin/pickup-slots/[id]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { PickupSlotForm } from '@/components/admin/PickupSlotForm';
|
||||
import { updatePickupSlot, deletePickupSlot } from '../actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Convert a UTC Date to a "YYYY-MM-DDTHH:mm" string in the server's local
|
||||
// timezone — that's the format datetime-local inputs expect.
|
||||
function toLocalInputValue(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default async function EditPickupSlotPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
searchParams: Promise<{ error?: string; count?: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.pickupSlots');
|
||||
const c = await getTranslations('common');
|
||||
const { error, count } = await searchParams;
|
||||
|
||||
const slot = await prisma.pickupSlot.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { bookings: true } } },
|
||||
});
|
||||
if (!slot) notFound();
|
||||
|
||||
const inUseCount = slot._count.bookings;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/pickup-slots" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">
|
||||
{t('edit')} ·{' '}
|
||||
<span className="text-ink-600">
|
||||
{locale === 'sv' ? slot.labelSv : slot.labelEn}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error === 'in-use' && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
{t('cannotDelete', { count: count ?? '?' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PickupSlotForm
|
||||
mode="edit"
|
||||
slot={{
|
||||
id: slot.id,
|
||||
labelSv: slot.labelSv,
|
||||
labelEn: slot.labelEn,
|
||||
startsAt: toLocalInputValue(slot.startsAt),
|
||||
endsAt: toLocalInputValue(slot.endsAt),
|
||||
capacity: slot.capacity,
|
||||
active: slot.active,
|
||||
}}
|
||||
action={updatePickupSlot}
|
||||
/>
|
||||
|
||||
<form
|
||||
action={deletePickupSlot}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
|
||||
>
|
||||
<input type="hidden" name="id" value={slot.id} />
|
||||
<div className="text-red-700">
|
||||
{inUseCount > 0
|
||||
? t('cannotDelete', { count: inUseCount })
|
||||
: t('deleteConfirm')}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inUseCount > 0}
|
||||
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/app/[locale]/admin/pickup-slots/actions.ts
Normal file
101
src/app/[locale]/admin/pickup-slots/actions.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
|
||||
// datetime-local inputs return "YYYY-MM-DDTHH:mm" in the browser's local
|
||||
// timezone. We treat them as local time and convert via Date constructor
|
||||
// (which interprets the string in the server's timezone — for a single-host
|
||||
// deploy that's fine; document if you ever shard across timezones).
|
||||
const slotSchema = z
|
||||
.object({
|
||||
labelSv: z.string().trim().min(1).max(200),
|
||||
labelEn: z.string().trim().min(1).max(200),
|
||||
startsAt: z.string().trim().min(1),
|
||||
endsAt: z.string().trim().min(1),
|
||||
capacity: z.coerce.number().int().min(0).max(10000),
|
||||
active: z.preprocess(
|
||||
(v) => v === 'on' || v === 'true' || v === true,
|
||||
z.boolean(),
|
||||
),
|
||||
})
|
||||
.refine((d) => new Date(d.startsAt) < new Date(d.endsAt), {
|
||||
path: ['endsAt'],
|
||||
message: 'invalidTime',
|
||||
});
|
||||
|
||||
type Parsed = z.infer<typeof slotSchema>;
|
||||
|
||||
function toDbFields(d: Parsed) {
|
||||
return {
|
||||
labelSv: d.labelSv,
|
||||
labelEn: d.labelEn,
|
||||
startsAt: new Date(d.startsAt),
|
||||
endsAt: new Date(d.endsAt),
|
||||
capacity: d.capacity,
|
||||
active: d.active,
|
||||
};
|
||||
}
|
||||
|
||||
function fromFormData(fd: FormData): Parsed {
|
||||
return slotSchema.parse({
|
||||
labelSv: fd.get('labelSv') ?? '',
|
||||
labelEn: fd.get('labelEn') ?? '',
|
||||
startsAt: fd.get('startsAt') ?? '',
|
||||
endsAt: fd.get('endsAt') ?? '',
|
||||
capacity: fd.get('capacity') ?? 0,
|
||||
active: fd.get('active') ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPickupSlot(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const data = fromFormData(formData);
|
||||
await prisma.pickupSlot.create({ data: toDbFields(data) });
|
||||
revalidatePath('/admin/pickup-slots');
|
||||
revalidatePath('/');
|
||||
redirect('/admin/pickup-slots');
|
||||
}
|
||||
|
||||
export async function updatePickupSlot(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const data = fromFormData(formData);
|
||||
await prisma.pickupSlot.update({ where: { id }, data: toDbFields(data) });
|
||||
revalidatePath('/admin/pickup-slots');
|
||||
revalidatePath(`/admin/pickup-slots/${id}`);
|
||||
revalidatePath('/');
|
||||
redirect('/admin/pickup-slots');
|
||||
}
|
||||
|
||||
export async function togglePickupSlotActive(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const cur = await prisma.pickupSlot.findUnique({ where: { id } });
|
||||
if (!cur) return;
|
||||
await prisma.pickupSlot.update({
|
||||
where: { id },
|
||||
data: { active: !cur.active },
|
||||
});
|
||||
revalidatePath('/admin/pickup-slots');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function deletePickupSlot(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const inUse = await prisma.booking.count({ where: { pickupSlotId: id } });
|
||||
if (inUse > 0) {
|
||||
redirect(`/admin/pickup-slots/${id}?error=in-use&count=${inUse}`);
|
||||
}
|
||||
await prisma.pickupSlot.delete({ where: { id } });
|
||||
revalidatePath('/admin/pickup-slots');
|
||||
revalidatePath('/');
|
||||
redirect('/admin/pickup-slots');
|
||||
}
|
||||
29
src/app/[locale]/admin/pickup-slots/new/page.tsx
Normal file
29
src/app/[locale]/admin/pickup-slots/new/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { PickupSlotForm } from '@/components/admin/PickupSlotForm';
|
||||
import { createPickupSlot } from '../actions';
|
||||
|
||||
export default async function NewPickupSlotPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.pickupSlots');
|
||||
const c = await getTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/pickup-slots" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
|
||||
</div>
|
||||
<PickupSlotForm mode="create" action={createPickupSlot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/[locale]/admin/pickup-slots/page.tsx
Normal file
137
src/app/[locale]/admin/pickup-slots/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { togglePickupSlotActive } from './actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminPickupSlotsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.pickupSlots');
|
||||
const loc = locale as 'sv' | 'en';
|
||||
|
||||
const slots = await prisma.pickupSlot.findMany({
|
||||
orderBy: { startsAt: 'asc' },
|
||||
include: { _count: { select: { bookings: true } } },
|
||||
});
|
||||
|
||||
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>
|
||||
<Link href="/admin/pickup-slots/new" className="btn-primary text-sm">
|
||||
+ {t('new')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{slots.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 text-xs uppercase tracking-wide text-ink-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">{t('columns.label')}</th>
|
||||
<th className="px-4 py-2.5">{t('columns.when')}</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.capacity')}
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.bookings')}
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-center">
|
||||
{t('columns.active')}
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{slots.map((s) => {
|
||||
const taken = s._count.bookings;
|
||||
const left = Math.max(0, s.capacity - taken);
|
||||
return (
|
||||
<tr
|
||||
key={s.id}
|
||||
className={`hover:bg-ink-50/50 ${s.active ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-ink-900">
|
||||
{loc === 'sv' ? s.labelSv : s.labelEn}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">
|
||||
{loc === 'sv' ? s.labelEn : s.labelSv}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div>
|
||||
{s.startsAt.toLocaleString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">
|
||||
→{' '}
|
||||
{s.endsAt.toLocaleString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ timeStyle: 'short' },
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
{s.capacity}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
{taken}
|
||||
<span className="ml-1 text-xs text-ink-400">
|
||||
({left} {loc === 'sv' ? 'kvar' : 'left'})
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<form
|
||||
action={togglePickupSlotActive}
|
||||
className="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={s.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className={`badge ${
|
||||
s.active
|
||||
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
|
||||
: 'bg-ink-200 text-ink-700 hover:bg-ink-300'
|
||||
}`}
|
||||
>
|
||||
{s.active ? '✓' : '○'}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Link
|
||||
href={`/admin/pickup-slots/${s.id}`}
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
{t('edit')} →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/app/[locale]/admin/products/[id]/page.tsx
Normal file
87
src/app/[locale]/admin/products/[id]/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { ProductForm } from '@/components/admin/ProductForm';
|
||||
import { updateProduct, deleteProduct } from '../actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EditProductPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
searchParams: Promise<{ error?: string; count?: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.products');
|
||||
const c = await getTranslations('common');
|
||||
const { error, count } = await searchParams;
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { bookingItems: true } } },
|
||||
});
|
||||
if (!product) notFound();
|
||||
|
||||
const inUseCount = product._count.bookingItems;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/products" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">
|
||||
{t('edit')} · <span className="font-mono">{product.sku}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error === 'in-use' && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
{t('cannotDelete', { count: count ?? '?' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductForm
|
||||
mode="edit"
|
||||
product={{
|
||||
id: product.id,
|
||||
sku: product.sku,
|
||||
nameSv: product.nameSv,
|
||||
nameEn: product.nameEn,
|
||||
descriptionSv: product.descriptionSv,
|
||||
descriptionEn: product.descriptionEn,
|
||||
priceOre: product.priceOre,
|
||||
vatBp: product.vatBp,
|
||||
sortOrder: product.sortOrder,
|
||||
active: product.active,
|
||||
}}
|
||||
action={updateProduct}
|
||||
/>
|
||||
|
||||
<form
|
||||
action={deleteProduct}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
|
||||
>
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
<div className="text-red-700">
|
||||
{inUseCount > 0
|
||||
? t('cannotDelete', { count: inUseCount })
|
||||
: t('deleteConfirm')}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inUseCount > 0}
|
||||
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/app/[locale]/admin/products/actions.ts
Normal file
106
src/app/[locale]/admin/products/actions.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
|
||||
// Form coercion: HTML number inputs send strings; we sanitize to int/float.
|
||||
const productSchema = z.object({
|
||||
sku: z.string().trim().min(1).max(50),
|
||||
nameSv: z.string().trim().min(1).max(200),
|
||||
nameEn: z.string().trim().min(1).max(200),
|
||||
descriptionSv: z.string().trim().max(2000),
|
||||
descriptionEn: z.string().trim().max(2000),
|
||||
priceSek: z.coerce.number().min(0).max(1_000_000),
|
||||
vatPct: z.coerce.number().min(0).max(100),
|
||||
sortOrder: z.coerce.number().int().min(0).max(10000),
|
||||
active: z.preprocess(
|
||||
(v) => v === 'on' || v === 'true' || v === true,
|
||||
z.boolean(),
|
||||
),
|
||||
});
|
||||
|
||||
type Parsed = z.infer<typeof productSchema>;
|
||||
|
||||
function toDbFields(d: Parsed) {
|
||||
return {
|
||||
sku: d.sku,
|
||||
nameSv: d.nameSv,
|
||||
nameEn: d.nameEn,
|
||||
descriptionSv: d.descriptionSv,
|
||||
descriptionEn: d.descriptionEn,
|
||||
priceOre: Math.round(d.priceSek * 100),
|
||||
vatBp: Math.round(d.vatPct * 100),
|
||||
sortOrder: d.sortOrder,
|
||||
active: d.active,
|
||||
};
|
||||
}
|
||||
|
||||
function fromFormData(fd: FormData): Parsed {
|
||||
const obj = {
|
||||
sku: fd.get('sku') ?? '',
|
||||
nameSv: fd.get('nameSv') ?? '',
|
||||
nameEn: fd.get('nameEn') ?? '',
|
||||
descriptionSv: fd.get('descriptionSv') ?? '',
|
||||
descriptionEn: fd.get('descriptionEn') ?? '',
|
||||
priceSek: fd.get('priceSek') ?? 0,
|
||||
vatPct: fd.get('vatPct') ?? 25,
|
||||
sortOrder: fd.get('sortOrder') ?? 0,
|
||||
active: fd.get('active') ?? false,
|
||||
};
|
||||
return productSchema.parse(obj);
|
||||
}
|
||||
|
||||
export async function createProduct(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const data = fromFormData(formData);
|
||||
await prisma.product.create({ data: toDbFields(data) });
|
||||
revalidatePath('/admin/products');
|
||||
revalidatePath('/');
|
||||
redirect('/admin/products');
|
||||
}
|
||||
|
||||
export async function updateProduct(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const data = fromFormData(formData);
|
||||
await prisma.product.update({ where: { id }, data: toDbFields(data) });
|
||||
revalidatePath('/admin/products');
|
||||
revalidatePath(`/admin/products/${id}`);
|
||||
revalidatePath('/');
|
||||
redirect('/admin/products');
|
||||
}
|
||||
|
||||
export async function toggleProductActive(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
const cur = await prisma.product.findUnique({ where: { id } });
|
||||
if (!cur) return;
|
||||
await prisma.product.update({
|
||||
where: { id },
|
||||
data: { active: !cur.active },
|
||||
});
|
||||
revalidatePath('/admin/products');
|
||||
revalidatePath('/');
|
||||
}
|
||||
|
||||
export async function deleteProduct(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
// Guard: never delete products that have booking history — would break
|
||||
// invoicing/reporting. Deactivate is the right move there.
|
||||
const inUse = await prisma.bookingItem.count({ where: { productId: id } });
|
||||
if (inUse > 0) {
|
||||
// Soft-redirect with error hint in query.
|
||||
redirect(`/admin/products/${id}?error=in-use&count=${inUse}`);
|
||||
}
|
||||
await prisma.product.delete({ where: { id } });
|
||||
revalidatePath('/admin/products');
|
||||
revalidatePath('/');
|
||||
redirect('/admin/products');
|
||||
}
|
||||
29
src/app/[locale]/admin/products/new/page.tsx
Normal file
29
src/app/[locale]/admin/products/new/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { ProductForm } from '@/components/admin/ProductForm';
|
||||
import { createProduct } from '../actions';
|
||||
|
||||
export default async function NewProductPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.products');
|
||||
const c = await getTranslations('common');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/products" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
|
||||
</div>
|
||||
<ProductForm mode="create" action={createProduct} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/app/[locale]/admin/products/page.tsx
Normal file
115
src/app/[locale]/admin/products/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { formatOre } from '@/lib/money';
|
||||
import { toggleProductActive } from './actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminProductsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.products');
|
||||
const loc = locale as 'sv' | 'en';
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
orderBy: [{ active: 'desc' }, { sortOrder: 'asc' }],
|
||||
include: { _count: { select: { bookingItems: true } } },
|
||||
});
|
||||
|
||||
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>
|
||||
<Link href="/admin/products/new" className="btn-primary text-sm">
|
||||
+ {t('new')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{products.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 text-xs uppercase tracking-wide text-ink-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">{t('columns.sku')}</th>
|
||||
<th className="px-4 py-2.5">{t('columns.name')}</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.price')}
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.vat')}
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-center">
|
||||
{t('columns.active')}
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{products.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className={`hover:bg-ink-50/50 ${p.active ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-mono text-xs">{p.sku}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-ink-900">
|
||||
{loc === 'sv' ? p.nameSv : p.nameEn}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">
|
||||
{p._count.bookingItems}{' '}
|
||||
{loc === 'sv' ? 'bokningar' : 'bookings'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
{formatOre(p.priceOre, loc)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-500">
|
||||
{(p.vatBp / 100).toFixed(0)}%
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<form action={toggleProductActive} className="inline">
|
||||
<input type="hidden" name="id" value={p.id} />
|
||||
<button
|
||||
type="submit"
|
||||
className={`badge ${
|
||||
p.active
|
||||
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
|
||||
: 'bg-ink-200 text-ink-700 hover:bg-ink-300'
|
||||
}`}
|
||||
>
|
||||
{p.active ? t('active') : t('inactive')}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Link
|
||||
href={`/admin/products/${p.id}`}
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
{t('edit')} →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/app/[locale]/admin/settings/actions.ts
Normal file
17
src/app/[locale]/admin/settings/actions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { updateSettings } from '@/lib/settings';
|
||||
|
||||
export async function saveSettings(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const pickupEnabled = formData.get('pickupEnabled') === 'on';
|
||||
await updateSettings({ pickupEnabled });
|
||||
|
||||
// Public booking form depends on this — bust its cache.
|
||||
revalidatePath('/', 'layout');
|
||||
revalidatePath('/admin/settings');
|
||||
redirect('/admin/settings?saved=1');
|
||||
}
|
||||
58
src/app/[locale]/admin/settings/page.tsx
Normal file
58
src/app/[locale]/admin/settings/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { getSettings } from '@/lib/settings';
|
||||
import { saveSettings } from './actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminSettingsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ saved?: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.settings');
|
||||
const { saved } = await searchParams;
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-ink-900">{t('title')}</h1>
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{t('saved')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={saveSettings} className="card space-y-5 p-5">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="pickupEnabled"
|
||||
defaultChecked={settings.pickupEnabled}
|
||||
className="mt-0.5 h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-ink-900">
|
||||
{t('pickupEnabled')}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-ink-500">
|
||||
{t('pickupEnabledHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end border-t border-ink-200 pt-4">
|
||||
<button type="submit" className="btn-primary">
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/app/[locale]/admin/users/[id]/page.tsx
Normal file
115
src/app/[locale]/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import {
|
||||
AdminUserForm,
|
||||
PasswordChangeForm,
|
||||
} from '@/components/admin/AdminUserForm';
|
||||
import {
|
||||
updateAdmin,
|
||||
changeAdminPassword,
|
||||
deleteAdmin,
|
||||
} from '../actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const KNOWN_ERRORS = [
|
||||
'emailInUse',
|
||||
'cannotDeleteSelf',
|
||||
'cannotDeleteLast',
|
||||
'passwordMismatch',
|
||||
'passwordTooShort',
|
||||
] as const;
|
||||
type ErrorKey = (typeof KNOWN_ERRORS)[number];
|
||||
|
||||
const KNOWN_OK = ['passwordChanged'] as const;
|
||||
type OkKey = (typeof KNOWN_OK)[number];
|
||||
|
||||
export default async function EditAdminUserPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
searchParams: Promise<{ error?: string; ok?: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await requireAdmin();
|
||||
const t = await getTranslations('admin.users');
|
||||
const c = await getTranslations('common');
|
||||
const { error, ok } = await searchParams;
|
||||
|
||||
const admin = await prisma.admin.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
if (!admin) notFound();
|
||||
|
||||
const isMe = session.user?.id === admin.id;
|
||||
const adminCount = await prisma.admin.count();
|
||||
const canDelete = !isMe && adminCount > 1;
|
||||
|
||||
const errorKey =
|
||||
error && (KNOWN_ERRORS as readonly string[]).includes(error)
|
||||
? (error as ErrorKey)
|
||||
: null;
|
||||
const okKey =
|
||||
ok && (KNOWN_OK as readonly string[]).includes(ok) ? (ok as OkKey) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/users" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">
|
||||
{t('edit')} ·{' '}
|
||||
<span className="text-ink-600">{admin.name}</span>
|
||||
{isMe && (
|
||||
<span className="ml-2 text-sm font-normal text-accent-600">
|
||||
{t('you')}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{errorKey && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{t(errorKey)}
|
||||
</div>
|
||||
)}
|
||||
{okKey && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{t(okKey)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminUserForm mode="edit" admin={admin} action={updateAdmin} />
|
||||
|
||||
<PasswordChangeForm adminId={admin.id} action={changeAdminPassword} />
|
||||
|
||||
<form
|
||||
action={deleteAdmin}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50/50 p-3 text-sm"
|
||||
>
|
||||
<input type="hidden" name="id" value={admin.id} />
|
||||
<div className="text-red-700">
|
||||
{isMe
|
||||
? t('cannotDeleteSelf')
|
||||
: adminCount <= 1
|
||||
? t('cannotDeleteLast')
|
||||
: t('deleteConfirm')}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canDelete}
|
||||
className="btn border border-red-300 bg-white text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/app/[locale]/admin/users/actions.ts
Normal file
147
src/app/[locale]/admin/users/actions.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
const baseFields = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
email: z.string().trim().toLowerCase().email().max(200),
|
||||
});
|
||||
|
||||
const passwordField = z
|
||||
.string()
|
||||
.min(8, 'passwordTooShort')
|
||||
.max(200);
|
||||
|
||||
const createSchema = baseFields.extend({
|
||||
password: passwordField,
|
||||
passwordConfirm: z.string(),
|
||||
}).refine((d) => d.password === d.passwordConfirm, {
|
||||
path: ['passwordConfirm'],
|
||||
message: 'passwordMismatch',
|
||||
});
|
||||
|
||||
const updateSchema = baseFields;
|
||||
|
||||
const passwordChangeSchema = z
|
||||
.object({
|
||||
password: passwordField,
|
||||
passwordConfirm: z.string(),
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirm, {
|
||||
path: ['passwordConfirm'],
|
||||
message: 'passwordMismatch',
|
||||
});
|
||||
|
||||
function fail(id: string, code: string): never {
|
||||
redirect(`/admin/users/${id}?error=${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
function failNew(code: string): never {
|
||||
redirect(`/admin/users/new?error=${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export async function createAdmin(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = createSchema.safeParse({
|
||||
name: formData.get('name') ?? '',
|
||||
email: formData.get('email') ?? '',
|
||||
password: formData.get('password') ?? '',
|
||||
passwordConfirm: formData.get('passwordConfirm') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
const code = parsed.error.errors[0]?.message ?? 'invalid';
|
||||
failNew(code);
|
||||
}
|
||||
const { name, email, password } = parsed.data;
|
||||
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await prisma.admin.create({
|
||||
data: { name, email, passwordHash: hash },
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Prisma.PrismaClientKnownRequestError;
|
||||
if (err.code === 'P2002') failNew('emailInUse');
|
||||
throw e;
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
redirect('/admin/users');
|
||||
}
|
||||
|
||||
export async function updateAdmin(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const parsed = updateSchema.safeParse({
|
||||
name: formData.get('name') ?? '',
|
||||
email: formData.get('email') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail(id, parsed.error.errors[0]?.message ?? 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.admin.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Prisma.PrismaClientKnownRequestError;
|
||||
if (err.code === 'P2002') fail(id, 'emailInUse');
|
||||
throw e;
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
revalidatePath(`/admin/users/${id}`);
|
||||
redirect('/admin/users');
|
||||
}
|
||||
|
||||
export async function changeAdminPassword(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
const parsed = passwordChangeSchema.safeParse({
|
||||
password: formData.get('password') ?? '',
|
||||
passwordConfirm: formData.get('passwordConfirm') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail(id, parsed.error.errors[0]?.message ?? 'invalid');
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(parsed.data.password, 12);
|
||||
await prisma.admin.update({
|
||||
where: { id },
|
||||
data: { passwordHash: hash },
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/users/${id}`);
|
||||
redirect(`/admin/users/${id}?ok=passwordChanged`);
|
||||
}
|
||||
|
||||
export async function deleteAdmin(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const id = String(formData.get('id') ?? '');
|
||||
if (!id) return;
|
||||
|
||||
// Two safeguards: you can't lock yourself out, and you can't empty the table.
|
||||
if (session.user?.id === id) {
|
||||
fail(id, 'cannotDeleteSelf');
|
||||
}
|
||||
const count = await prisma.admin.count();
|
||||
if (count <= 1) {
|
||||
fail(id, 'cannotDeleteLast');
|
||||
}
|
||||
|
||||
await prisma.admin.delete({ where: { id } });
|
||||
revalidatePath('/admin/users');
|
||||
redirect('/admin/users');
|
||||
}
|
||||
46
src/app/[locale]/admin/users/new/page.tsx
Normal file
46
src/app/[locale]/admin/users/new/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
import { AdminUserForm } from '@/components/admin/AdminUserForm';
|
||||
import { createAdmin } from '../actions';
|
||||
|
||||
export default async function NewAdminUserPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
await requireAdmin();
|
||||
const t = await getTranslations('admin.users');
|
||||
const c = await getTranslations('common');
|
||||
const { error } = await searchParams;
|
||||
|
||||
const errorKey = error as
|
||||
| 'emailInUse'
|
||||
| 'passwordMismatch'
|
||||
| 'passwordTooShort'
|
||||
| undefined;
|
||||
const errorMsg = errorKey ? t(errorKey) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/users" className="btn-ghost text-sm">
|
||||
← {c('back')}
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold text-ink-900">{t('new')}</h1>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminUserForm mode="create" action={createAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/app/[locale]/admin/users/page.tsx
Normal file
89
src/app/[locale]/admin/users/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdmin } from '@/lib/requireAdmin';
|
||||
import { Link } from '@/i18n/routing';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminUsersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await requireAdmin();
|
||||
const t = await getTranslations('admin.users');
|
||||
const loc = locale as 'sv' | 'en';
|
||||
const currentUserId = session.user?.id;
|
||||
|
||||
const admins = await prisma.admin.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, name: true, email: true, createdAt: true },
|
||||
});
|
||||
|
||||
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>
|
||||
<Link href="/admin/users/new" className="btn-primary text-sm">
|
||||
+ {t('new')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{admins.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 text-xs uppercase tracking-wide text-ink-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5">{t('columns.name')}</th>
|
||||
<th className="px-4 py-2.5">{t('columns.email')}</th>
|
||||
<th className="px-4 py-2.5">{t('columns.created')}</th>
|
||||
<th className="px-4 py-2.5 text-right">
|
||||
{t('columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{admins.map((a) => {
|
||||
const isMe = a.id === currentUserId;
|
||||
return (
|
||||
<tr key={a.id} className="hover:bg-ink-50/50">
|
||||
<td className="px-4 py-2.5 font-medium text-ink-900">
|
||||
{a.name}
|
||||
{isMe && (
|
||||
<span className="ml-2 text-xs font-normal text-accent-600">
|
||||
{t('you')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-700">{a.email}</td>
|
||||
<td className="px-4 py-2.5 text-ink-500">
|
||||
{a.createdAt.toLocaleDateString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Link
|
||||
href={`/admin/users/${a.id}`}
|
||||
className="text-brand-600 hover:underline"
|
||||
>
|
||||
{t('edit')} →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/app/[locale]/booking/[number]/page.tsx
Normal file
129
src/app/[locale]/booking/[number]/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { Header } from '@/components/Header';
|
||||
import { formatOre } from '@/lib/money';
|
||||
import { Link } from '@/i18n/routing';
|
||||
|
||||
export default async function BookingConfirmedPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; number: string }>;
|
||||
}) {
|
||||
const { locale, number } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations();
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { bookingNumber: number },
|
||||
include: { items: true, pickupSlot: true },
|
||||
});
|
||||
if (!booking) notFound();
|
||||
|
||||
const loc = locale as 'sv' | 'en';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink-50">
|
||||
<Header />
|
||||
<main className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="bg-brand-600 px-6 py-5 text-white">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm4.7-12.3a1 1 0 0 0-1.4-1.4L11 12.6 8.7 10.3a1 1 0 1 0-1.4 1.4l3 3a1 1 0 0 0 1.4 0l5-5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('booking.success.title')}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-semibold">
|
||||
{t('booking.success.bookingNumber', { number: booking.bookingNumber })}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-white/80">
|
||||
{t('booking.success.subtitle', { email: booking.email })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{t('email.orderSummary')}
|
||||
</h2>
|
||||
<table className="mt-2 w-full text-sm">
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{booking.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td className="py-2">
|
||||
{loc === 'sv' ? it.nameSv : it.nameEn}
|
||||
<span className="ml-2 text-ink-400">×{it.quantity}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatOre(it.lineTotalOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-ink-200">
|
||||
<td className="pt-2 text-ink-600">
|
||||
{t('common.subtotal')}
|
||||
</td>
|
||||
<td className="pt-2 text-right tabular-nums">
|
||||
{formatOre(booking.subtotalOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="text-ink-500">{t('common.ofWhichVat')}</td>
|
||||
<td className="text-right tabular-nums text-ink-500">
|
||||
{formatOre(booking.vatOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="pt-1 font-semibold">{t('common.total')}</td>
|
||||
<td className="pt-1 text-right font-semibold tabular-nums">
|
||||
{formatOre(booking.totalOre, loc)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{booking.pickupSlot && (
|
||||
<div>
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{t('email.pickup')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm">
|
||||
{loc === 'sv'
|
||||
? booking.pickupSlot.labelSv
|
||||
: booking.pickupSlot.labelEn}{' '}
|
||||
·{' '}
|
||||
{booking.pickupSlot.startsAt.toLocaleString(
|
||||
loc === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg bg-ink-50 p-4 text-sm text-ink-600">
|
||||
{t('email.invoiceInfo')}
|
||||
</div>
|
||||
|
||||
<Link href="/" className="btn-secondary w-full justify-center">
|
||||
{t('booking.success.newOrder')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/layout.tsx
Normal file
35
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, setRequestLocale } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as 'sv' | 'en')) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
78
src/app/[locale]/page.tsx
Normal file
78
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { setRequestLocale, getTranslations } from 'next-intl/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getSettings } from '@/lib/settings';
|
||||
import { Header } from '@/components/Header';
|
||||
import { BookingForm } from '@/components/BookingForm';
|
||||
|
||||
// Render fresh so toggling settings, products or slots in admin reflects
|
||||
// immediately on the public form. revalidatePath() in admin actions covers
|
||||
// most cases, but force-dynamic removes any cache surprise.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function BookingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('booking');
|
||||
|
||||
const [products, slotsRaw, settings] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
}),
|
||||
prisma.pickupSlot.findMany({
|
||||
where: { active: true, endsAt: { gte: new Date() } },
|
||||
orderBy: { startsAt: 'asc' },
|
||||
include: { _count: { select: { bookings: true } } },
|
||||
}),
|
||||
getSettings(),
|
||||
]);
|
||||
|
||||
const slots = slotsRaw.map((s) => ({
|
||||
id: s.id,
|
||||
labelSv: s.labelSv,
|
||||
labelEn: s.labelEn,
|
||||
startsAt: s.startsAt.toISOString(),
|
||||
endsAt: s.endsAt.toISOString(),
|
||||
capacityLeft: Math.max(0, s.capacity - s._count.bookings),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink-50">
|
||||
<Header />
|
||||
<main className="mx-auto max-w-5xl px-4 py-6 sm:py-10">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-ink-900 sm:text-3xl">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-ink-600">{t('intro')}</p>
|
||||
</div>
|
||||
<BookingForm
|
||||
products={products.map((p) => ({
|
||||
id: p.id,
|
||||
sku: p.sku,
|
||||
nameSv: p.nameSv,
|
||||
nameEn: p.nameEn,
|
||||
descriptionSv: p.descriptionSv,
|
||||
descriptionEn: p.descriptionEn,
|
||||
priceOre: p.priceOre,
|
||||
vatBp: p.vatBp,
|
||||
}))}
|
||||
pickupSlots={settings.pickupEnabled ? slots : []}
|
||||
pickupEnabled={settings.pickupEnabled}
|
||||
/>
|
||||
</main>
|
||||
<footer className="border-t border-ink-200 bg-white">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 text-xs text-ink-500">
|
||||
© {new Date().getFullYear()} Gasol247 ·{' '}
|
||||
<a href="https://www.gasol247.com" className="hover:underline">
|
||||
gasol247.com
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user