initial booking
This commit is contained in:
825
src/components/BookingForm.tsx
Normal file
825
src/components/BookingForm.tsx
Normal file
@@ -0,0 +1,825 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/routing';
|
||||
import { formatOre, computeTotals, priceInclVatOre } from '@/lib/money';
|
||||
import { isValidSeOrgNumber } from '@/lib/orgNumber';
|
||||
import { useBookingStore, type Step } from '@/lib/bookingStore';
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
sku: string;
|
||||
nameSv: string;
|
||||
nameEn: string;
|
||||
descriptionSv: string;
|
||||
descriptionEn: string;
|
||||
priceOre: number;
|
||||
vatBp: number;
|
||||
};
|
||||
|
||||
type PickupSlot = {
|
||||
id: string;
|
||||
labelSv: string;
|
||||
labelEn: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
capacityLeft: number;
|
||||
};
|
||||
|
||||
const ALL_STEPS: Step[] = ['products', 'details', 'pickup', 'review'];
|
||||
|
||||
export function BookingForm({
|
||||
products,
|
||||
pickupSlots,
|
||||
pickupEnabled,
|
||||
}: {
|
||||
products: Product[];
|
||||
pickupSlots: PickupSlot[];
|
||||
pickupEnabled: boolean;
|
||||
}) {
|
||||
// Step list adapts to whether pickup is enabled at all.
|
||||
const STEPS: Step[] = pickupEnabled
|
||||
? ALL_STEPS
|
||||
: ALL_STEPS.filter((s) => s !== 'pickup');
|
||||
const t = useTranslations('booking');
|
||||
const c = useTranslations('common');
|
||||
const locale = useLocale() as 'sv' | 'en';
|
||||
const router = useRouter();
|
||||
|
||||
// Persisted state — survives language switch and tab refresh.
|
||||
const store = useBookingStore();
|
||||
const {
|
||||
step,
|
||||
quantities,
|
||||
contactName,
|
||||
email,
|
||||
phone,
|
||||
orgName,
|
||||
orgNumber,
|
||||
address,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
pickupSlotId,
|
||||
notes,
|
||||
setStep,
|
||||
setQuantity,
|
||||
patch,
|
||||
reset,
|
||||
} = store;
|
||||
|
||||
// Confirm checkbox is intentionally not persisted; keep local.
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Avoid hydration mismatch: don't render store-backed UI until mounted.
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
useEffect(() => setHydrated(true), []);
|
||||
|
||||
// If the persisted step no longer exists (admin toggled pickup off mid-flow),
|
||||
// bump back to the closest still-valid step.
|
||||
useEffect(() => {
|
||||
if (!STEPS.includes(step)) {
|
||||
setStep('products');
|
||||
}
|
||||
}, [step, STEPS, setStep]);
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
return products
|
||||
.map((p) => {
|
||||
const qty = quantities[p.id] ?? 0;
|
||||
return qty > 0
|
||||
? {
|
||||
product: p,
|
||||
quantity: qty,
|
||||
unitPriceOre: p.priceOre,
|
||||
vatBp: p.vatBp,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
}, [products, quantities]);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
computeTotals(
|
||||
selectedItems.map((i) => ({
|
||||
unitPriceOre: i.unitPriceOre,
|
||||
quantity: i.quantity,
|
||||
vatBp: i.vatBp,
|
||||
})),
|
||||
),
|
||||
[selectedItems],
|
||||
);
|
||||
|
||||
const stepIndex = STEPS.indexOf(step);
|
||||
|
||||
// Per-step validity. Used both for the Next button and for deciding which
|
||||
// step boxes are clickable.
|
||||
const isStepValid = (s: Step): boolean => {
|
||||
if (s === 'products') return selectedItems.length > 0;
|
||||
if (s === 'details') {
|
||||
return (
|
||||
contactName.trim().length >= 2 &&
|
||||
/^\S+@\S+\.\S+$/.test(email) &&
|
||||
phone.trim().length >= 5 &&
|
||||
orgName.trim().length >= 2 &&
|
||||
isValidSeOrgNumber(orgNumber) &&
|
||||
address.trim().length >= 2 &&
|
||||
postalCode.trim().length >= 3 &&
|
||||
city.trim().length >= 1
|
||||
);
|
||||
}
|
||||
if (s === 'pickup') return true; // optional
|
||||
if (s === 'review') return confirm;
|
||||
return true;
|
||||
};
|
||||
|
||||
const canGoNext = (): boolean => isStepValid(step);
|
||||
|
||||
// A target step is reachable if you go backwards, or if all earlier steps
|
||||
// are valid (so you can jump ahead in a wizard).
|
||||
const canReachStep = (target: Step): boolean => {
|
||||
const targetIdx = STEPS.indexOf(target);
|
||||
if (targetIdx <= stepIndex) return true;
|
||||
for (let i = 0; i < targetIdx; i++) {
|
||||
if (!isStepValid(STEPS[i])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const goToStep = (target: Step) => {
|
||||
if (canReachStep(target)) setStep(target);
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
const idx = STEPS.indexOf(step);
|
||||
if (idx < STEPS.length - 1) setStep(STEPS[idx + 1]);
|
||||
};
|
||||
const goPrev = () => {
|
||||
const idx = STEPS.indexOf(step);
|
||||
if (idx > 0) setStep(STEPS[idx - 1]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const payload = {
|
||||
contactName,
|
||||
email,
|
||||
phone,
|
||||
orgName,
|
||||
orgNumber,
|
||||
address,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
// Drop any pickup slot the user might have selected before admin
|
||||
// disabled the step.
|
||||
pickupSlotId: pickupEnabled ? pickupSlotId : null,
|
||||
notes: notes || null,
|
||||
locale,
|
||||
items: selectedItems.map((i) => ({
|
||||
productId: i.product.id,
|
||||
quantity: i.quantity,
|
||||
})),
|
||||
};
|
||||
const res = await fetch('/api/bookings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error ?? 'submitFailed');
|
||||
}
|
||||
const data = (await res.json()) as { bookingNumber: string };
|
||||
reset();
|
||||
router.push(`/booking/${data.bookingNumber}`);
|
||||
} catch {
|
||||
setSubmitError(t('errors.submitFailed'));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render a stable placeholder before hydration so SSR markup matches.
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepIndicator
|
||||
steps={STEPS}
|
||||
step="products"
|
||||
goToStep={() => {}}
|
||||
canReachStep={() => false}
|
||||
/>
|
||||
<div className="card p-6 text-sm text-ink-500">…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepIndicator
|
||||
steps={STEPS}
|
||||
step={step}
|
||||
goToStep={goToStep}
|
||||
canReachStep={canReachStep}
|
||||
/>
|
||||
|
||||
<div className="card p-4 sm:p-6">
|
||||
{step === 'products' && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-ink-900">
|
||||
{t('products.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-ink-500">{t('products.subtitle')}</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{products.map((p) => {
|
||||
const qty = quantities[p.id] ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex flex-col gap-3 rounded-lg border p-4 transition-colors sm:flex-row sm:items-center sm:justify-between ${
|
||||
qty > 0
|
||||
? 'border-accent-300 bg-accent-50/40'
|
||||
: 'border-ink-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-ink-900">
|
||||
{locale === 'sv' ? p.nameSv : p.nameEn}
|
||||
</div>
|
||||
<span className="badge bg-ink-100 text-ink-600">
|
||||
{p.sku}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink-500">
|
||||
{locale === 'sv' ? p.descriptionSv : p.descriptionEn}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<div className="text-sm font-medium text-ink-700">
|
||||
{formatOre(priceInclVatOre(p.priceOre, p.vatBp), locale)}
|
||||
<span className="text-ink-400">
|
||||
{' '}
|
||||
{t('products.perUnit')}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-ink-400">
|
||||
({c('inclVat')})
|
||||
</span>
|
||||
</div>
|
||||
{qty > 0 && (
|
||||
<div className="text-sm font-semibold text-brand-700 tabular-nums">
|
||||
={' '}
|
||||
{formatOre(
|
||||
priceInclVatOre(p.priceOre, p.vatBp) * qty,
|
||||
locale,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity(p.id, qty - 1)}
|
||||
disabled={qty === 0}
|
||||
className="btn-secondary !px-3 !py-2"
|
||||
aria-label={`-1 ${p.sku}`}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={999}
|
||||
value={qty}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setQuantity(p.id, Number.isFinite(n) ? n : 0);
|
||||
}}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="num-input"
|
||||
aria-label={`${c('quantity')} ${p.sku}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity(p.id, qty + 1)}
|
||||
className="btn-primary !px-3 !py-2"
|
||||
aria-label={`+1 ${p.sku}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedItems.length === 0 && (
|
||||
<p className="mt-3 text-sm text-ink-500">
|
||||
{t('products.noneSelected')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 'details' && (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-ink-900">
|
||||
{t('details.title')}
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-ink-700">
|
||||
{t('details.organization')}
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t('details.orgName')}
|
||||
required
|
||||
value={orgName}
|
||||
onChange={(v) => patch({ orgName: v })}
|
||||
placeholder={t('details.orgNamePlaceholder')}
|
||||
/>
|
||||
<Field
|
||||
label={t('details.orgNumber')}
|
||||
required
|
||||
value={orgNumber}
|
||||
onChange={(v) => patch({ orgNumber: v })}
|
||||
placeholder={t('details.orgNumberPlaceholder')}
|
||||
error={
|
||||
orgNumber.length > 0 && !isValidSeOrgNumber(orgNumber)
|
||||
? t('errors.invalidOrgNumber')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-ink-700">
|
||||
{t('details.contact')}
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t('details.contactName')}
|
||||
required
|
||||
value={contactName}
|
||||
onChange={(v) => patch({ contactName: v })}
|
||||
placeholder={t('details.contactNamePlaceholder')}
|
||||
/>
|
||||
<Field
|
||||
label={t('details.email')}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(v) => patch({ email: v })}
|
||||
placeholder={t('details.emailPlaceholder')}
|
||||
error={
|
||||
email.length > 0 && !/^\S+@\S+\.\S+$/.test(email)
|
||||
? t('errors.invalidEmail')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label={t('details.phone')}
|
||||
required
|
||||
value={phone}
|
||||
onChange={(v) => patch({ phone: v })}
|
||||
placeholder={t('details.phonePlaceholder')}
|
||||
type="tel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-ink-700">
|
||||
{t('details.invoiceAddress')}
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Field
|
||||
label={t('details.address')}
|
||||
required
|
||||
value={address}
|
||||
onChange={(v) => patch({ address: v })}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label={t('details.postalCode')}
|
||||
required
|
||||
value={postalCode}
|
||||
onChange={(v) => patch({ postalCode: v })}
|
||||
/>
|
||||
<Field
|
||||
label={t('details.city')}
|
||||
required
|
||||
value={city}
|
||||
onChange={(v) => patch({ city: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 'pickup' && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-ink-900">
|
||||
{t('pickup.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-ink-500">{t('pickup.subtitle')}</p>
|
||||
|
||||
{pickupSlots.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-ink-500">{t('pickup.noSlots')}</p>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{pickupSlots.map((s) => {
|
||||
const selected = pickupSlotId === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
patch({ pickupSlotId: selected ? null : s.id })
|
||||
}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-accent-400 bg-accent-50/40 ring-2 ring-accent-300/40'
|
||||
: 'border-ink-200 bg-white hover:bg-ink-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-ink-900">
|
||||
{locale === 'sv' ? s.labelSv : s.labelEn}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-500">
|
||||
{new Date(s.startsAt).toLocaleString(
|
||||
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
)}
|
||||
{' – '}
|
||||
{new Date(s.endsAt).toLocaleString(
|
||||
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ timeStyle: 'short' },
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-ink-500">
|
||||
{t('pickup.capacity', { count: s.capacityLeft })}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="label" htmlFor="notes">
|
||||
{t('notes.label')}
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
className="input mt-1 min-h-24"
|
||||
placeholder={t('notes.placeholder')}
|
||||
value={notes}
|
||||
onChange={(e) => patch({ notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{step === 'review' && (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-ink-900">
|
||||
{t('review.title')}
|
||||
</h2>
|
||||
|
||||
<ReviewSection
|
||||
title={c('subtotal')}
|
||||
rows={selectedItems.map((i) => ({
|
||||
left: `${i.quantity} × ${locale === 'sv' ? i.product.nameSv : i.product.nameEn}`,
|
||||
right: formatOre(
|
||||
priceInclVatOre(i.unitPriceOre, i.vatBp) * i.quantity,
|
||||
locale,
|
||||
),
|
||||
}))}
|
||||
footer={
|
||||
<>
|
||||
<Row
|
||||
label={`${c('subtotal')} (${c('exclVat')})`}
|
||||
value={formatOre(totals.subtotalOre, locale)}
|
||||
muted
|
||||
/>
|
||||
<Row
|
||||
label={c('ofWhichVat')}
|
||||
value={formatOre(totals.vatOre, locale)}
|
||||
muted
|
||||
/>
|
||||
<Row
|
||||
label={`${c('total')} (${c('inclVat')})`}
|
||||
value={formatOre(totals.totalOre, locale)}
|
||||
bold
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ReviewSection
|
||||
title={t('details.organization')}
|
||||
rows={[
|
||||
{ left: t('details.orgName'), right: orgName },
|
||||
{ left: t('details.orgNumber'), right: orgNumber },
|
||||
]}
|
||||
/>
|
||||
<ReviewSection
|
||||
title={t('details.contact')}
|
||||
rows={[
|
||||
{ left: t('details.contactName'), right: contactName },
|
||||
{ left: t('details.email'), right: email },
|
||||
{ left: t('details.phone'), right: phone },
|
||||
]}
|
||||
/>
|
||||
<ReviewSection
|
||||
title={t('details.invoiceAddress')}
|
||||
rows={[
|
||||
{ left: t('details.address'), right: address },
|
||||
{
|
||||
left: `${t('details.postalCode')} / ${t('details.city')}`,
|
||||
right: `${postalCode} ${city}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{pickupEnabled && pickupSlotId && (
|
||||
<ReviewSection
|
||||
title={t('pickup.title')}
|
||||
rows={(() => {
|
||||
const slot = pickupSlots.find((s) => s.id === pickupSlotId);
|
||||
if (!slot) return [];
|
||||
return [
|
||||
{
|
||||
left: locale === 'sv' ? slot.labelSv : slot.labelEn,
|
||||
right: new Date(slot.startsAt).toLocaleString(
|
||||
locale === 'sv' ? 'sv-SE' : 'en-SE',
|
||||
{ dateStyle: 'medium', timeStyle: 'short' },
|
||||
),
|
||||
},
|
||||
];
|
||||
})()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-3 rounded-lg border border-ink-200 bg-ink-50/50 p-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirm}
|
||||
onChange={(e) => setConfirm(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||
/>
|
||||
<span>{t('review.confirm')}</span>
|
||||
</label>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SummaryBar
|
||||
totals={totals}
|
||||
itemCount={selectedItems.reduce((acc, i) => acc + i.quantity, 0)}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={stepIndex === 0 || submitting}
|
||||
className="btn-secondary"
|
||||
>
|
||||
← {t('previous')}
|
||||
</button>
|
||||
{step !== 'review' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{t('next')} →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canGoNext() || submitting}
|
||||
className="btn-primary"
|
||||
>
|
||||
{submitting ? t('review.submitting') : c('submit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIndicator({
|
||||
steps,
|
||||
step,
|
||||
goToStep,
|
||||
canReachStep,
|
||||
}: {
|
||||
steps: Step[];
|
||||
step: Step;
|
||||
goToStep: (s: Step) => void;
|
||||
canReachStep: (s: Step) => boolean;
|
||||
}) {
|
||||
const t = useTranslations('booking');
|
||||
const longLabels: Record<Step, string> = {
|
||||
products: t('stepProducts'),
|
||||
details: t('stepDetails'),
|
||||
pickup: t('stepPickup'),
|
||||
review: t('stepReview'),
|
||||
};
|
||||
const currentIdx = steps.indexOf(step);
|
||||
// Mobile: 2-up grid (with 3 steps, last cell wraps to its own row — that's
|
||||
// intentional, it puts emphasis on the final step). sm+: one row.
|
||||
const desktopCols = steps.length === 4 ? 'sm:grid-cols-4' : 'sm:grid-cols-3';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ol className={`grid grid-cols-2 gap-1.5 ${desktopCols} sm:gap-2`}>
|
||||
{steps.map((s, i) => {
|
||||
const active = s === step;
|
||||
const done = i < currentIdx;
|
||||
const reachable = canReachStep(s);
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep(s)}
|
||||
disabled={!reachable}
|
||||
aria-current={active ? 'step' : undefined}
|
||||
className={`flex w-full items-center gap-2 rounded-lg border p-2 text-left transition-colors sm:gap-3 ${
|
||||
active
|
||||
? 'border-accent-400 bg-accent-50/60 text-accent-700'
|
||||
: done
|
||||
? 'border-brand-300 bg-brand-50/40 text-brand-700 hover:bg-brand-50'
|
||||
: reachable
|
||||
? 'border-ink-200 bg-white text-ink-600 hover:bg-ink-50'
|
||||
: 'cursor-not-allowed border-ink-200 bg-white text-ink-300'
|
||||
} ${reachable ? '' : 'opacity-70'}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold sm:h-7 sm:w-7 ${
|
||||
active
|
||||
? 'bg-accent-400 text-white'
|
||||
: done
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-ink-100 text-ink-500'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{done ? '✓' : i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium leading-tight">
|
||||
{longLabels[s]}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="text-center text-xs text-ink-500 sm:hidden">
|
||||
{t('stepOf', { current: currentIdx + 1, total: steps.length })} ·{' '}
|
||||
<span className="font-medium text-ink-700">{longLabels[step]}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
error,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
className="input mt-1"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewSection({
|
||||
title,
|
||||
rows,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
rows: { left: string; right: string }[];
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-200">
|
||||
<div className="border-b border-ink-200 bg-ink-50/50 px-4 py-2 text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{title}
|
||||
</div>
|
||||
<div className="divide-y divide-ink-100">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between px-4 py-2 text-sm"
|
||||
>
|
||||
<span className="text-ink-600">{r.left}</span>
|
||||
<span className="text-ink-900">{r.right}</span>
|
||||
</div>
|
||||
))}
|
||||
{footer && <div className="bg-ink-50/30 px-4 py-2">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
muted,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
muted?: boolean;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between text-sm ${muted ? 'text-ink-500' : 'text-ink-900'} ${bold ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryBar({
|
||||
totals,
|
||||
itemCount,
|
||||
locale,
|
||||
}: {
|
||||
totals: { subtotalOre: number; vatOre: number; totalOre: number };
|
||||
itemCount: number;
|
||||
locale: 'sv' | 'en';
|
||||
}) {
|
||||
const c = useTranslations('common');
|
||||
if (itemCount === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-brand-600 bg-white px-4 py-3 text-sm shadow-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-ink-600">
|
||||
{itemCount} {c('quantity').toLowerCase()}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold tabular-nums text-brand-700">
|
||||
{formatOre(totals.totalOre, locale)}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-400">
|
||||
{c('inclVat')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-ink-400">
|
||||
{c('ofWhichVat')} {formatOre(totals.vatOre, locale)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/Header.tsx
Normal file
35
src/components/Header.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations('header');
|
||||
const c = useTranslations('common');
|
||||
return (
|
||||
<header className="border-b border-brand-700 bg-brand-600 text-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:py-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white p-1 shadow-sm sm:h-12 sm:w-12 sm:p-1.5">
|
||||
<Image
|
||||
src="/gasol247-logo.png"
|
||||
alt="Gasol247"
|
||||
width={48}
|
||||
height={62}
|
||||
priority
|
||||
className="h-full w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold sm:text-base">
|
||||
{c('siteName')}
|
||||
</div>
|
||||
<div className="truncate text-xs text-white/80">
|
||||
{t('tagline')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
46
src/components/LanguageSwitcher.tsx
Normal file
46
src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from '@/i18n/routing';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const current = (params?.locale as string) ?? 'sv';
|
||||
|
||||
const switchTo = (next: 'sv' | 'en') => {
|
||||
router.replace(pathname, { locale: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex shrink-0 overflow-hidden rounded-lg border border-white/20 bg-white/10 text-xs font-medium backdrop-blur-sm"
|
||||
role="group"
|
||||
aria-label="Language switcher"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchTo('sv')}
|
||||
className={`px-3 py-1.5 transition-colors ${
|
||||
current === 'sv'
|
||||
? 'bg-accent-300 text-brand-900'
|
||||
: 'text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
SV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchTo('en')}
|
||||
className={`px-3 py-1.5 transition-colors ${
|
||||
current === 'en'
|
||||
? 'bg-accent-300 text-brand-900'
|
||||
: 'text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/components/StatusBadge.tsx
Normal file
32
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-800',
|
||||
CONFIRMED: 'bg-sky-100 text-sky-800',
|
||||
DELIVERED_PARTIAL: 'bg-blue-100 text-blue-800',
|
||||
DELIVERED: 'bg-emerald-100 text-emerald-800',
|
||||
RETURNED_PARTIAL: 'bg-teal-100 text-teal-800',
|
||||
RETURNED: 'bg-slate-200 text-slate-800',
|
||||
INVOICED: 'bg-violet-100 text-violet-800',
|
||||
CANCELLED: 'bg-ink-200 text-ink-700',
|
||||
};
|
||||
|
||||
const KNOWN = new Set([
|
||||
'PENDING',
|
||||
'CONFIRMED',
|
||||
'DELIVERED_PARTIAL',
|
||||
'DELIVERED',
|
||||
'RETURNED_PARTIAL',
|
||||
'RETURNED',
|
||||
'INVOICED',
|
||||
'CANCELLED',
|
||||
] as const);
|
||||
|
||||
type Known = Parameters<typeof KNOWN.has>[0];
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const t = useTranslations('admin.bookings.status');
|
||||
const cls = COLORS[status] ?? 'bg-ink-100 text-ink-700';
|
||||
const label = KNOWN.has(status as Known) ? t(status as Known) : status;
|
||||
return <span className={`badge ${cls}`}>{label}</span>;
|
||||
}
|
||||
79
src/components/admin/AdminFilters.tsx
Normal file
79
src/components/admin/AdminFilters.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
// Debounced filter bar — writes search and status to the URL, which the
|
||||
// server component re-reads. No submit button.
|
||||
export function AdminFilters() {
|
||||
const t = useTranslations('admin.bookings');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const initialQ = searchParams.get('q') ?? '';
|
||||
const initialStatus = searchParams.get('status') ?? 'all';
|
||||
|
||||
const [q, setQ] = useState(initialQ);
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
|
||||
// Debounced URL push when q changes.
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
if (q) next.set('q', q);
|
||||
else next.delete('q');
|
||||
// Reset to page 1 when filter changes.
|
||||
next.delete('page');
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
|
||||
});
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q]);
|
||||
|
||||
const onStatusChange = (next: string) => {
|
||||
setStatus(next);
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
if (next === 'all') sp.delete('status');
|
||||
else sp.set('status', next);
|
||||
sp.delete('page');
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${sp.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('filters.search')}
|
||||
className="input max-w-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="input max-w-xs"
|
||||
>
|
||||
<option value="all">{t('filters.all')}</option>
|
||||
<option value="CONFIRMED">{t('status.CONFIRMED')}</option>
|
||||
<option value="DELIVERED_PARTIAL">
|
||||
{t('status.DELIVERED_PARTIAL')}
|
||||
</option>
|
||||
<option value="DELIVERED">{t('status.DELIVERED')}</option>
|
||||
<option value="RETURNED_PARTIAL">
|
||||
{t('status.RETURNED_PARTIAL')}
|
||||
</option>
|
||||
<option value="RETURNED">{t('status.RETURNED')}</option>
|
||||
<option value="INVOICED">{t('status.INVOICED')}</option>
|
||||
<option value="CANCELLED">{t('status.CANCELLED')}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/admin/AdminUserForm.tsx
Normal file
158
src/components/admin/AdminUserForm.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/routing';
|
||||
|
||||
type Admin = {
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export function AdminUserForm({
|
||||
mode,
|
||||
admin,
|
||||
action,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
admin?: Admin;
|
||||
action: (fd: FormData) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('admin.users');
|
||||
const c = useTranslations('common');
|
||||
|
||||
const a: Admin = admin ?? { name: '', email: '' };
|
||||
|
||||
return (
|
||||
<form action={action} className="card space-y-5 p-5">
|
||||
{a.id && <input type="hidden" name="id" value={a.id} />}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
name="name"
|
||||
label={t('fields.name')}
|
||||
defaultValue={a.name}
|
||||
required
|
||||
autoFocus={mode === 'create'}
|
||||
/>
|
||||
<Field
|
||||
name="email"
|
||||
label={t('fields.email')}
|
||||
type="email"
|
||||
defaultValue={a.email}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
{mode === 'create' && (
|
||||
<>
|
||||
<Field
|
||||
name="password"
|
||||
label={t('fields.password')}
|
||||
hint={t('fields.passwordHint')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="passwordConfirm"
|
||||
label={t('fields.passwordConfirm')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
|
||||
<Link href="/admin/users" className="btn-secondary">
|
||||
{c('cancel')}
|
||||
</Link>
|
||||
<button type="submit" className="btn-primary">
|
||||
{mode === 'create' ? t('create') : t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordChangeForm({
|
||||
adminId,
|
||||
action,
|
||||
}: {
|
||||
adminId: string;
|
||||
action: (fd: FormData) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('admin.users');
|
||||
|
||||
return (
|
||||
<form action={action} className="card space-y-5 p-5">
|
||||
<h2 className="text-sm font-semibold text-ink-900">
|
||||
{t('changePassword')}
|
||||
</h2>
|
||||
<input type="hidden" name="id" value={adminId} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
name="password"
|
||||
label={t('fields.newPassword')}
|
||||
hint={t('fields.passwordHint')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="passwordConfirm"
|
||||
label={t('fields.passwordConfirm')}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-ink-200 pt-4">
|
||||
<button type="submit" className="btn-primary">
|
||||
{t('changePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
type = 'text',
|
||||
defaultValue,
|
||||
required,
|
||||
autoFocus,
|
||||
autoComplete,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
autoFocus?: boolean;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete={autoComplete}
|
||||
className="input mt-1"
|
||||
/>
|
||||
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/components/admin/FulfillmentTable.tsx
Normal file
198
src/components/admin/FulfillmentTable.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import {
|
||||
setItemFulfillment,
|
||||
markAllDelivered,
|
||||
markAllReturned,
|
||||
} from '@/app/[locale]/admin/bookings/[id]/actions';
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
sku: string;
|
||||
nameSv: string;
|
||||
nameEn: string;
|
||||
quantity: number;
|
||||
deliveredQuantity: number;
|
||||
returnedQuantity: number;
|
||||
};
|
||||
|
||||
export function FulfillmentTable({
|
||||
bookingId,
|
||||
items,
|
||||
}: {
|
||||
bookingId: string;
|
||||
items: Item[];
|
||||
}) {
|
||||
const t = useTranslations('admin.bookings.fulfillment');
|
||||
const locale = useLocale() as 'sv' | 'en';
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-200">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50/50 px-4 py-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-ink-500">
|
||||
{t('title')}
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<form
|
||||
action={(fd) => startTransition(() => markAllDelivered(fd))}
|
||||
>
|
||||
<input type="hidden" name="bookingId" value={bookingId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-secondary !px-2.5 !py-1 text-xs"
|
||||
>
|
||||
↑ {t('deliverAll')}
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
action={(fd) => startTransition(() => markAllReturned(fd))}
|
||||
>
|
||||
<input type="hidden" name="bookingId" value={bookingId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="btn-secondary !px-2.5 !py-1 text-xs"
|
||||
>
|
||||
↓ {t('returnAll')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-ink-500">
|
||||
<th className="px-4 py-2">SKU</th>
|
||||
<th className="px-4 py-2">
|
||||
{locale === 'sv' ? 'Namn' : 'Name'}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right">{t('ordered')}</th>
|
||||
<th className="px-4 py-2 text-right">{t('delivered')}</th>
|
||||
<th className="px-4 py-2 text-right">{t('returned')}</th>
|
||||
<th className="px-4 py-2 text-right">{t('outstanding')}</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{items.map((it) => (
|
||||
<FulfillmentRow
|
||||
key={it.id}
|
||||
bookingId={bookingId}
|
||||
item={it}
|
||||
locale={locale}
|
||||
pending={pending}
|
||||
startTransition={startTransition}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FulfillmentRow({
|
||||
bookingId,
|
||||
item,
|
||||
locale,
|
||||
pending,
|
||||
startTransition,
|
||||
}: {
|
||||
bookingId: string;
|
||||
item: Item;
|
||||
locale: 'sv' | 'en';
|
||||
pending: boolean;
|
||||
startTransition: React.TransitionStartFunction;
|
||||
}) {
|
||||
const t = useTranslations('admin.bookings.fulfillment');
|
||||
const [delivered, setDelivered] = useState(item.deliveredQuantity);
|
||||
const [returned, setReturned] = useState(item.returnedQuantity);
|
||||
|
||||
const outstanding = Math.max(0, delivered - returned);
|
||||
const dirty =
|
||||
delivered !== item.deliveredQuantity || returned !== item.returnedQuantity;
|
||||
const validDelivered =
|
||||
Number.isFinite(delivered) && delivered >= 0 && delivered <= item.quantity;
|
||||
const validReturned =
|
||||
Number.isFinite(returned) && returned >= 0 && returned <= delivered;
|
||||
const canSave = dirty && validDelivered && validReturned && !pending;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="px-4 py-2 font-mono text-xs">{item.sku}</td>
|
||||
<td className="px-4 py-2">
|
||||
{locale === 'sv' ? item.nameSv : item.nameEn}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-ink-600">
|
||||
{item.quantity}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={item.quantity}
|
||||
value={delivered}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setDelivered(Number.isFinite(n) ? n : 0);
|
||||
}}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`num-input ${validDelivered ? '' : 'border-red-400'}`}
|
||||
aria-label={t('delivered')}
|
||||
title={t('deliveredHint')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={delivered}
|
||||
value={returned}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
setReturned(Number.isFinite(n) ? n : 0);
|
||||
}}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`num-input ${validReturned ? '' : 'border-red-400'}`}
|
||||
aria-label={t('returned')}
|
||||
title={t('returnedHint')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">
|
||||
<span
|
||||
className={
|
||||
outstanding > 0 ? 'font-semibold text-brand-700' : 'text-ink-400'
|
||||
}
|
||||
>
|
||||
{outstanding}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<form
|
||||
action={(fd) => {
|
||||
fd.set('delivered', String(delivered));
|
||||
fd.set('returned', String(returned));
|
||||
startTransition(() => setItemFulfillment(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="bookingId" value={bookingId} />
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave}
|
||||
className="btn-primary !px-3 !py-1 text-xs"
|
||||
>
|
||||
{t('save')}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
55
src/components/admin/Pagination.tsx
Normal file
55
src/components/admin/Pagination.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
type Props = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export function Pagination({ page, pageSize, total }: Props) {
|
||||
const sp = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const to = Math.min(total, page * pageSize);
|
||||
|
||||
const link = (p: number) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
if (p <= 1) next.delete('page');
|
||||
else next.set('page', String(p));
|
||||
return `${pathname}?${next.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-ink-600">
|
||||
<div>
|
||||
{from}–{to} / {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={link(Math.max(1, page - 1))}
|
||||
aria-disabled={page <= 1}
|
||||
tabIndex={page <= 1 ? -1 : undefined}
|
||||
className={`btn-secondary !px-3 !py-1.5 text-xs ${page <= 1 ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
←
|
||||
</Link>
|
||||
<span className="px-2 tabular-nums">
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<Link
|
||||
href={link(Math.min(pages, page + 1))}
|
||||
aria-disabled={page >= pages}
|
||||
tabIndex={page >= pages ? -1 : undefined}
|
||||
className={`btn-secondary !px-3 !py-1.5 text-xs ${page >= pages ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/components/admin/PickupSlotForm.tsx
Normal file
149
src/components/admin/PickupSlotForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/routing';
|
||||
|
||||
type Slot = {
|
||||
id?: string;
|
||||
labelSv: string;
|
||||
labelEn: string;
|
||||
startsAt: string; // "YYYY-MM-DDTHH:mm" local
|
||||
endsAt: string;
|
||||
capacity: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export function PickupSlotForm({
|
||||
mode,
|
||||
slot,
|
||||
action,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
slot?: Slot;
|
||||
action: (fd: FormData) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('admin.pickupSlots');
|
||||
const c = useTranslations('common');
|
||||
|
||||
const s: Slot = slot ?? {
|
||||
labelSv: '',
|
||||
labelEn: '',
|
||||
startsAt: defaultDateString(0, 9),
|
||||
endsAt: defaultDateString(0, 12),
|
||||
capacity: 50,
|
||||
active: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={action} className="card space-y-5 p-5">
|
||||
{s.id && <input type="hidden" name="id" value={s.id} />}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
name="labelSv"
|
||||
label={t('fields.labelSv')}
|
||||
hint={t('fields.labelHint')}
|
||||
defaultValue={s.labelSv}
|
||||
required
|
||||
autoFocus={mode === 'create'}
|
||||
/>
|
||||
<Field
|
||||
name="labelEn"
|
||||
label={t('fields.labelEn')}
|
||||
defaultValue={s.labelEn}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="startsAt"
|
||||
label={t('fields.startsAt')}
|
||||
type="datetime-local"
|
||||
defaultValue={s.startsAt}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="endsAt"
|
||||
label={t('fields.endsAt')}
|
||||
type="datetime-local"
|
||||
defaultValue={s.endsAt}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="capacity"
|
||||
label={t('fields.capacity')}
|
||||
hint={t('fields.capacityHint')}
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={String(s.capacity)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="active"
|
||||
defaultChecked={s.active}
|
||||
className="h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||
/>
|
||||
<span>{t('fields.active')}</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
|
||||
<Link href="/admin/pickup-slots" className="btn-secondary">
|
||||
{c('cancel')}
|
||||
</Link>
|
||||
<button type="submit" className="btn-primary">
|
||||
{mode === 'create' ? t('create') : t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultDateString(daysAhead: number, hour: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysAhead);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
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())}`;
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
type = 'text',
|
||||
defaultValue,
|
||||
required,
|
||||
min,
|
||||
autoFocus,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
min={min}
|
||||
autoFocus={autoFocus}
|
||||
className="input mt-1"
|
||||
/>
|
||||
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/components/admin/ProductForm.tsx
Normal file
198
src/components/admin/ProductForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/routing';
|
||||
|
||||
type Product = {
|
||||
id?: string;
|
||||
sku: string;
|
||||
nameSv: string;
|
||||
nameEn: string;
|
||||
descriptionSv: string;
|
||||
descriptionEn: string;
|
||||
priceOre: number;
|
||||
vatBp: number;
|
||||
sortOrder: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export function ProductForm({
|
||||
mode,
|
||||
product,
|
||||
action,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
product?: Product;
|
||||
action: (fd: FormData) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('admin.products');
|
||||
const c = useTranslations('common');
|
||||
|
||||
const p: Product = product ?? {
|
||||
sku: '',
|
||||
nameSv: '',
|
||||
nameEn: '',
|
||||
descriptionSv: '',
|
||||
descriptionEn: '',
|
||||
priceOre: 0,
|
||||
vatBp: 2500,
|
||||
sortOrder: 100,
|
||||
active: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<form action={action} className="card space-y-5 p-5">
|
||||
{p.id && <input type="hidden" name="id" value={p.id} />}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
name="sku"
|
||||
label={t('fields.sku')}
|
||||
hint={t('fields.skuHint')}
|
||||
defaultValue={p.sku}
|
||||
required
|
||||
autoFocus={mode === 'create'}
|
||||
/>
|
||||
<Field
|
||||
name="sortOrder"
|
||||
label={t('fields.sortOrder')}
|
||||
hint={t('fields.sortOrderHint')}
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={String(p.sortOrder)}
|
||||
/>
|
||||
<Field
|
||||
name="nameSv"
|
||||
label={t('fields.nameSv')}
|
||||
defaultValue={p.nameSv}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="nameEn"
|
||||
label={t('fields.nameEn')}
|
||||
defaultValue={p.nameEn}
|
||||
required
|
||||
/>
|
||||
<TextArea
|
||||
name="descriptionSv"
|
||||
label={t('fields.descriptionSv')}
|
||||
defaultValue={p.descriptionSv}
|
||||
/>
|
||||
<TextArea
|
||||
name="descriptionEn"
|
||||
label={t('fields.descriptionEn')}
|
||||
defaultValue={p.descriptionEn}
|
||||
/>
|
||||
<Field
|
||||
name="priceSek"
|
||||
label={t('fields.priceSek')}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={(p.priceOre / 100).toFixed(2)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="vatPct"
|
||||
label={t('fields.vatPct')}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={(p.vatBp / 100).toFixed(2)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="active"
|
||||
defaultChecked={p.active}
|
||||
className="h-4 w-4 rounded border-ink-300 text-accent-500 focus:ring-accent-400"
|
||||
/>
|
||||
<span>{t('fields.active')}</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-ink-200 pt-4">
|
||||
<Link href="/admin/products" className="btn-secondary">
|
||||
{c('cancel')}
|
||||
</Link>
|
||||
<button type="submit" className="btn-primary">
|
||||
{mode === 'create' ? t('create') : t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
type = 'text',
|
||||
defaultValue,
|
||||
required,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
autoFocus,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
step?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="ml-1 text-accent-600">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
autoFocus={autoFocus}
|
||||
className="input mt-1"
|
||||
/>
|
||||
{hint && <p className="mt-1 text-xs text-ink-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="sm:col-span-2">
|
||||
<label className="label" htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
id={name}
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
rows={2}
|
||||
className="input mt-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/admin/SortHeader.tsx
Normal file
42
src/components/admin/SortHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||
|
||||
type Props = {
|
||||
field: string;
|
||||
label: string;
|
||||
align?: 'left' | 'right';
|
||||
};
|
||||
|
||||
export function SortHeader({ field, label, align = 'left' }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
const currentSort = sp.get('sort') ?? 'createdAt';
|
||||
const currentDir = sp.get('dir') === 'asc' ? 'asc' : 'desc';
|
||||
const active = currentSort === field;
|
||||
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
next.set('sort', field);
|
||||
next.set('dir', active && currentDir === 'desc' ? 'asc' : 'desc');
|
||||
// Keep page on column toggle — feels less jumpy than resetting.
|
||||
|
||||
const onClick = () => {
|
||||
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 text-xs font-medium uppercase tracking-wide ${
|
||||
active ? 'text-brand-700' : 'text-ink-500 hover:text-ink-700'
|
||||
} ${align === 'right' ? 'ml-auto flex-row-reverse' : ''}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span aria-hidden className="inline-block w-2 text-[10px] leading-none">
|
||||
{active ? (currentDir === 'asc' ? '▲' : '▼') : '↕'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user