feat(admin): Listen mit Status-Badges, Leerzustaenden und klaren Aktionen
Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus, wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich- Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft). Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine (neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
95b4b5953d
commit
a09057d960
12 files changed
+425
-285
No files matched your search
@@ -1,12 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminSearchForm,
|
||||
type AdminBadgeTone,
|
||||
} from "@/components/admin-list";
|
||||
import { updateBusinessInquiryStatusAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
const STATUS_TONES: Record<string, AdminBadgeTone> = {
|
||||
NEU: "pending",
|
||||
IN_BEARBEITUNG: "pending",
|
||||
ABGESCHLOSSEN: "positive",
|
||||
ABGELEHNT: "neutral",
|
||||
};
|
||||
|
||||
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -35,46 +48,40 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.title}</h1>
|
||||
<AdminPageHeader title={t.adminInquiriesPage.title} />
|
||||
|
||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
<AdminSearchForm
|
||||
query={query}
|
||||
label={t.adminInquiriesPage.searchLabel}
|
||||
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||
submitLabel={t.adminInquiriesPage.filterButton}
|
||||
resetHref="/admin/anfragen"
|
||||
resetLabel={t.adminInquiriesPage.resetFilter}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminInquiriesPage.filterButton}
|
||||
</button>
|
||||
{query ? (
|
||||
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
|
||||
{t.adminInquiriesPage.resetFilter}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
{inquiries.map((inquiry) => (
|
||||
<div key={inquiry.id} className="bg-background border border-ink/10 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-forest">{inquiry.companyName}</p>
|
||||
<AdminBadge tone={STATUS_TONES[inquiry.status] ?? "neutral"} pulse={inquiry.status === "NEU"}>
|
||||
{statusLabels[inquiry.status] ?? inquiry.status}
|
||||
</AdminBadge>
|
||||
</div>
|
||||
<p className="text-sm text-ink/70">
|
||||
{inquiry.contactPerson} · {inquiry.email}
|
||||
{inquiry.phone ? ` · ${inquiry.phone}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p>
|
||||
<p className="text-xs text-ink/65 mt-1 tabular-nums">{formatDate(inquiry.createdAt)}</p>
|
||||
</div>
|
||||
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="id" value={inquiry.id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={inquiry.status}
|
||||
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
||||
aria-label={t.adminInquiriesPage.statusSelectLabel}
|
||||
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
@@ -82,7 +89,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="text-xs text-forest underline">
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-forest px-3 py-1.5 text-xs text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{t.adminInquiriesPage.save}
|
||||
</button>
|
||||
</form>
|
||||
@@ -107,9 +117,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
|
||||
</div>
|
||||
))}
|
||||
{inquiries.length === 0 ? (
|
||||
<p className="text-ink/65">
|
||||
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
|
||||
</p>
|
||||
<AdminEmptyState
|
||||
bordered
|
||||
message={query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
@@ -16,8 +17,7 @@ export default async function AdminAuditLogPage() {
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
|
||||
<AdminPageHeader title={t.adminAuditLogPage.title} intro={t.adminAuditLogPage.intro(RECENT_LIMIT)} />
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
@@ -33,7 +33,7 @@ export default async function AdminAuditLogPage() {
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
||||
<td className="p-4 whitespace-nowrap tabular-nums text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
||||
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
|
||||
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
|
||||
<td className="p-4 whitespace-nowrap text-ink/70">
|
||||
@@ -45,7 +45,7 @@ export default async function AdminAuditLogPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null}
|
||||
{entries.length === 0 ? <AdminEmptyState message={t.adminAuditLogPage.noEntriesYet} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
AdminActionButton,
|
||||
AdminActionLink,
|
||||
AdminBadge,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
AdminSearchForm,
|
||||
} from "@/components/admin-list";
|
||||
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -32,37 +40,21 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
}),
|
||||
]);
|
||||
|
||||
const newUser = { href: "/admin/benutzer/neu", label: t.adminUsersPage.newUser };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
|
||||
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminUsersPage.newUser}
|
||||
</Link>
|
||||
</div>
|
||||
<AdminPageHeader title={t.adminUsersPage.title} action={newUser} />
|
||||
|
||||
|
||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
<AdminSearchForm
|
||||
query={query}
|
||||
label={t.adminUsersPage.searchLabel}
|
||||
placeholder={t.adminUsersPage.searchPlaceholder}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||
submitLabel={t.adminUsersPage.filterButton}
|
||||
resetHref="/admin/benutzer"
|
||||
resetLabel={t.adminUsersPage.resetFilter}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminUsersPage.filterButton}
|
||||
</button>
|
||||
{query ? (
|
||||
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
|
||||
{t.adminUsersPage.resetFilter}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
@@ -89,37 +81,43 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
|
||||
</td>
|
||||
<td className="p-4">{customer.email}</td>
|
||||
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
|
||||
<td className="p-4">
|
||||
{customer.blockedAt ? (
|
||||
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span>
|
||||
) : isTemporarilyLocked ? (
|
||||
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
|
||||
{customer.role === "ADMIN" ? (
|
||||
<AdminBadge tone="neutral">{roleLabels.ADMIN}</AdminBadge>
|
||||
) : (
|
||||
<span className="text-forest">{t.adminUsersPage.statusActive}</span>
|
||||
(roleLabels[customer.role] ?? customer.role)
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
|
||||
<td className="p-4">{formatDate(customer.createdAt)}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline">
|
||||
{t.adminUsersPage.edit}
|
||||
</Link>
|
||||
{customer.blockedAt ? (
|
||||
<AdminBadge tone="danger">{t.adminUsersPage.statusBlocked}</AdminBadge>
|
||||
) : isTemporarilyLocked ? (
|
||||
<AdminBadge tone="pending">{t.adminUsersPage.statusTemporarilyLocked}</AdminBadge>
|
||||
) : (
|
||||
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{customer.twoFactorEnabled ? (
|
||||
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
|
||||
) : (
|
||||
<span className="text-ink/55">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(customer.createdAt)}</td>
|
||||
<td className="p-4">
|
||||
<AdminRowActions>
|
||||
<AdminActionLink href={`/admin/benutzer/${customer.id}`}>{t.adminUsersPage.edit}</AdminActionLink>
|
||||
{!isSelf ? (
|
||||
customer.blockedAt ? (
|
||||
<form action={unblockCustomerAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-forest underline">
|
||||
{t.adminUsersPage.unblock}
|
||||
</button>
|
||||
<AdminActionButton>{t.adminUsersPage.unblock}</AdminActionButton>
|
||||
</form>
|
||||
) : (
|
||||
<form action={blockCustomerAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-ink/70 underline">
|
||||
{t.adminUsersPage.block}
|
||||
</button>
|
||||
<AdminActionButton tone="muted">{t.adminUsersPage.block}</AdminActionButton>
|
||||
</form>
|
||||
)
|
||||
) : null}
|
||||
@@ -129,12 +127,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminUsersPage.delete}
|
||||
</button>
|
||||
<AdminActionButton tone="danger">{t.adminUsersPage.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -142,9 +138,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
</tbody>
|
||||
</table>
|
||||
{customers.length === 0 ? (
|
||||
<p className="p-6 text-ink/65">
|
||||
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
|
||||
</p>
|
||||
<AdminEmptyState
|
||||
message={query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
|
||||
action={query ? undefined : newUser}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,15 @@ import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import {
|
||||
AdminActionButton,
|
||||
AdminBadge,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
AdminSearchForm,
|
||||
} from "@/components/admin-list";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { approveReviewAction, deleteReviewAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -34,28 +43,16 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminReviewsPage.title}</h1>
|
||||
<AdminPageHeader title={t.adminReviewsPage.title} />
|
||||
|
||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
<AdminSearchForm
|
||||
query={query}
|
||||
label={t.adminReviewsPage.searchLabel}
|
||||
placeholder={t.adminReviewsPage.searchPlaceholder}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||
submitLabel={t.adminReviewsPage.filterButton}
|
||||
resetHref="/admin/bewertungen"
|
||||
resetLabel={t.adminReviewsPage.resetFilter}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminReviewsPage.filterButton}
|
||||
</button>
|
||||
{query ? (
|
||||
<Link href="/admin/bewertungen" className="text-sm text-ink/65 underline">
|
||||
{t.adminReviewsPage.resetFilter}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
@@ -74,34 +71,34 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
|
||||
{reviews.map((review) => (
|
||||
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4">
|
||||
<Link href={`/honig/${review.product.slug}`} className="text-forest underline">
|
||||
<Link href={`/honig/${review.product.slug}`} className="link-draw text-forest">
|
||||
{review.product.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{review.customer.firstName} {review.customer.lastName}
|
||||
</td>
|
||||
<td className="p-4 text-honey-dark whitespace-nowrap">
|
||||
{"★".repeat(review.rating)}
|
||||
{"☆".repeat(5 - review.rating)}
|
||||
<td className="p-4 whitespace-nowrap">
|
||||
<StarRating value={review.rating} />
|
||||
<span className="sr-only">{review.rating} / 5</span>
|
||||
</td>
|
||||
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
||||
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
||||
<td className="p-4">
|
||||
{review.isApproved ? (
|
||||
<span className="text-forest">{t.adminReviewsPage.statusPublished}</span>
|
||||
<AdminBadge tone="positive">{t.adminReviewsPage.statusPublished}</AdminBadge>
|
||||
) : (
|
||||
<span className="text-honey-dark">{t.adminReviewsPage.statusPending}</span>
|
||||
<AdminBadge tone="pending" pulse>
|
||||
{t.adminReviewsPage.statusPending}
|
||||
</AdminBadge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<AdminRowActions>
|
||||
{!review.isApproved ? (
|
||||
<form action={approveReviewAction}>
|
||||
<input type="hidden" name="id" value={review.id} />
|
||||
<button type="submit" className="text-forest underline">
|
||||
{t.adminReviewsPage.approve}
|
||||
</button>
|
||||
<AdminActionButton>{t.adminReviewsPage.approve}</AdminActionButton>
|
||||
</form>
|
||||
) : null}
|
||||
<ConfirmForm
|
||||
@@ -109,20 +106,18 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
|
||||
confirmMessage={t.adminReviewsPage.deleteConfirm}
|
||||
>
|
||||
<input type="hidden" name="id" value={review.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminReviewsPage.delete}
|
||||
</button>
|
||||
<AdminActionButton tone="danger">{t.adminReviewsPage.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{reviews.length === 0 ? (
|
||||
<p className="p-6 text-ink/65">
|
||||
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
|
||||
</p>
|
||||
<AdminEmptyState
|
||||
message={query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { Button } from "@/components/ui";
|
||||
import { AdminField, AdminSelect } from "@/components/admin-form";
|
||||
import {
|
||||
AdminActionButton,
|
||||
AdminActionLink,
|
||||
AdminActionNote,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
} from "@/components/admin-list";
|
||||
import { createBatchAction, deleteBatchAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -20,7 +29,7 @@ export default async function AdminBatchesPage() {
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
||||
<AdminPageHeader title={t.adminBatchesPage.title} />
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
@@ -41,84 +50,70 @@ export default async function AdminBatchesPage() {
|
||||
<td className="p-4">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</td>
|
||||
<td className="p-4">{formatDate(batch.fillDate)}</td>
|
||||
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td>
|
||||
<td className="p-4">{batch.glassCount}</td>
|
||||
<td className="p-4">{batch.remainingGlasses}</td>
|
||||
<td className="p-4 whitespace-nowrap">
|
||||
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline mr-4">
|
||||
{t.adminBatchesPage.edit}
|
||||
</Link>
|
||||
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.fillDate)}</td>
|
||||
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.bestBeforeDate)}</td>
|
||||
<td className="p-4 tabular-nums">{batch.glassCount}</td>
|
||||
<td className={`p-4 tabular-nums ${batch.remainingGlasses === 0 ? "text-ink/55" : ""}`}>
|
||||
{batch.remainingGlasses}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<AdminRowActions>
|
||||
<AdminActionLink href={`/admin/chargen/${batch.id}`}>{t.adminBatchesPage.edit}</AdminActionLink>
|
||||
{batch._count.products === 0 && batch._count.orderItems === 0 ? (
|
||||
<ConfirmForm
|
||||
action={deleteBatchAction}
|
||||
confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)}
|
||||
className="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={batch.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminBatchesPage.delete}
|
||||
</button>
|
||||
<AdminActionButton tone="danger">{t.adminBatchesPage.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
) : (
|
||||
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||
<span className="text-xs text-ink/50">{t.adminBatchesPage.notDeletable}</span>
|
||||
<AdminActionNote>{t.adminBatchesPage.notDeletable}</AdminActionNote>
|
||||
)}
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{batches.length === 0 ? <p className="p-6 text-ink/65">{t.adminBatchesPage.noBatchesYet}</p> : null}
|
||||
{batches.length === 0 ? <AdminEmptyState message={t.adminBatchesPage.noBatchesYet} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||
<h2 className="font-serif text-xl text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||
<form action={createBatchAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
|
||||
<input
|
||||
<div className="grid grid-cols-2 items-end gap-4">
|
||||
<AdminField
|
||||
name="code"
|
||||
required
|
||||
label={t.adminBatchesPage.codeLabel}
|
||||
placeholder={t.adminBatchesPage.codePlaceholder}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.harvestLabel}</span>
|
||||
<select name="harvestId" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||
<AdminSelect name="harvestId" label={t.adminBatchesPage.harvestLabel}>
|
||||
{harvests.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.type} {h.year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</AdminSelect>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span>
|
||||
<input name="fillDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.bestBeforeDateLabel}</span>
|
||||
<input name="bestBeforeDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 items-end gap-4">
|
||||
<AdminField name="fillDate" label={t.adminBatchesPage.fillDateLabel} type="date" required />
|
||||
<AdminField name="bestBeforeDate" label={t.adminBatchesPage.bestBeforeDateLabel} type="date" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span>
|
||||
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span>
|
||||
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 items-end gap-4">
|
||||
<AdminField name="glassCount" label={t.adminBatchesPage.glassCountLabel} type="number" required />
|
||||
<AdminField
|
||||
name="remainingGlasses"
|
||||
label={t.adminBatchesPage.remainingGlassesLabel}
|
||||
type="number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-ink/10 pt-4">
|
||||
<Button type="submit">{t.adminBatchesPage.create}</Button>
|
||||
</div>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminBatchesPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { Button } from "@/components/ui";
|
||||
import { AdminField, AdminSwitch, AdminTextarea } from "@/components/admin-form";
|
||||
import {
|
||||
AdminActionButton,
|
||||
AdminActionLink,
|
||||
AdminActionNote,
|
||||
AdminBadge,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
} from "@/components/admin-list";
|
||||
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
|
||||
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -19,7 +29,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
||||
<AdminPageHeader title={t.adminHarvestsPage.title} />
|
||||
|
||||
<ApiCoreSyncResult searchParams={searchParams} />
|
||||
|
||||
@@ -41,74 +51,64 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
||||
{harvests.map((harvest) => (
|
||||
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">{harvest.type}</td>
|
||||
<td className="p-4">{harvest.year}</td>
|
||||
<td className="p-4">{formatDate(harvest.harvestDate)}</td>
|
||||
<td className="p-4 tabular-nums">{harvest.year}</td>
|
||||
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(harvest.harvestDate)}</td>
|
||||
<td className="p-4">{harvest.location}</td>
|
||||
<td className="p-4">{harvest._count.batches}</td>
|
||||
<td className="p-4">{harvest._count.products}</td>
|
||||
<td className="p-4 tabular-nums">{harvest._count.batches}</td>
|
||||
<td className="p-4 tabular-nums">{harvest._count.products}</td>
|
||||
<td className="p-4">
|
||||
<span
|
||||
className={
|
||||
harvest.isPublished
|
||||
? "text-forest bg-forest/10 px-2 py-1 text-xs"
|
||||
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs"
|
||||
}
|
||||
>
|
||||
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
|
||||
</span>
|
||||
{harvest.isPublished ? (
|
||||
<AdminBadge tone="positive">{t.adminHarvestsPage.statusPublished}</AdminBadge>
|
||||
) : (
|
||||
<AdminBadge tone="pending">{t.adminHarvestsPage.statusDraft}</AdminBadge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap">
|
||||
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4">
|
||||
{t.adminHarvestsPage.edit}
|
||||
</Link>
|
||||
<form action={toggleHarvestPublishedAction} className="inline">
|
||||
<td className="p-4">
|
||||
<AdminRowActions>
|
||||
<AdminActionLink href={`/admin/ernten/${harvest.id}`}>{t.adminHarvestsPage.edit}</AdminActionLink>
|
||||
<form action={toggleHarvestPublishedAction}>
|
||||
<input type="hidden" name="id" value={harvest.id} />
|
||||
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
|
||||
<button type="submit" className="text-forest underline mr-4">
|
||||
<AdminActionButton tone={harvest.isPublished ? "muted" : "default"}>
|
||||
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
|
||||
</button>
|
||||
</AdminActionButton>
|
||||
</form>
|
||||
{harvest._count.batches === 0 && harvest._count.products === 0 ? (
|
||||
<ConfirmForm
|
||||
action={deleteHarvestAction}
|
||||
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
|
||||
className="inline"
|
||||
>
|
||||
<input type="hidden" name="id" value={harvest.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminHarvestsPage.delete}
|
||||
</button>
|
||||
<AdminActionButton tone="danger">{t.adminHarvestsPage.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
) : (
|
||||
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
|
||||
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span>
|
||||
<AdminActionNote>{t.adminHarvestsPage.notDeletable}</AdminActionNote>
|
||||
)}
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
|
||||
{harvests.length === 0 ? <AdminEmptyState message={t.adminHarvestsPage.noHarvestsYet} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<h2 className="font-serif text-xl text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<form action={createHarvestAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||
<div className="grid grid-cols-2 items-end gap-4">
|
||||
<AdminField name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||
<AdminField name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||
</div>
|
||||
<AdminField name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||
<AdminField name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||
<AdminTextarea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||
<AdminSwitch name="isPublished" label={t.adminHarvestsPage.isPublishedLabel} defaultChecked />
|
||||
<div className="flex justify-end border-t border-ink/10 pt-4">
|
||||
<Button type="submit">{t.adminHarvestsPage.create}</Button>
|
||||
</div>
|
||||
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isPublished" defaultChecked />
|
||||
{t.adminHarvestsPage.isPublishedLabel}
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestsPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -118,38 +118,3 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import {
|
||||
AdminActionButton,
|
||||
AdminActionLink,
|
||||
AdminBadge,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
} from "@/components/admin-list";
|
||||
import { deleteDiscountCodeAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -10,17 +17,13 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminGutscheinePage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||
const now = new Date();
|
||||
const newVoucher = { href: "/admin/gutscheine/neu", label: t.adminVouchersPage.newVoucher };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl text-forest">{t.adminVouchersPage.title}</h1>
|
||||
<Link href="/admin/gutscheine/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminVouchersPage.newVoucher}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<AdminPageHeader title={t.adminVouchersPage.title} action={newVoucher} />
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
@@ -45,7 +48,7 @@ export default async function AdminGutscheinePage() {
|
||||
</td>
|
||||
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
|
||||
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
|
||||
<td className="p-4">
|
||||
<td className="p-4 tabular-nums">
|
||||
{code.usedCount}
|
||||
{code.maxUses !== null ? ` / ${code.maxUses}` : ""}
|
||||
</td>
|
||||
@@ -54,33 +57,31 @@ export default async function AdminGutscheinePage() {
|
||||
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{code.isActive ? (
|
||||
<span className="text-forest">{t.adminVouchersPage.statusActive}</span>
|
||||
{!code.isActive ? (
|
||||
<AdminBadge tone="neutral">{t.adminVouchersPage.statusInactive}</AdminBadge>
|
||||
) : code.validUntil && code.validUntil < now ? (
|
||||
<AdminBadge tone="pending">{t.adminVouchersPage.statusExpired}</AdminBadge>
|
||||
) : (
|
||||
<span className="text-ink/65">{t.adminVouchersPage.statusInactive}</span>
|
||||
<AdminBadge tone="positive">{t.adminVouchersPage.statusActive}</AdminBadge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline">
|
||||
{t.adminVouchersPage.edit}
|
||||
</Link>
|
||||
<AdminRowActions>
|
||||
<AdminActionLink href={`/admin/gutscheine/${code.id}`}>{t.adminVouchersPage.edit}</AdminActionLink>
|
||||
<ConfirmForm
|
||||
action={deleteDiscountCodeAction}
|
||||
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
|
||||
>
|
||||
<input type="hidden" name="id" value={code.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminVouchersPage.delete}
|
||||
</button>
|
||||
<AdminActionButton tone="danger">{t.adminVouchersPage.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{codes.length === 0 ? <p className="p-6 text-ink/65">{t.adminVouchersPage.noVouchersYet}</p> : null}
|
||||
{codes.length === 0 ? <AdminEmptyState message={t.adminVouchersPage.noVouchersYet} action={newVoucher} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import {
|
||||
AdminActionButton,
|
||||
AdminActionLink,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
} from "@/components/admin-list";
|
||||
import { deleteShippingTierAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -10,19 +16,12 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminVersandPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||
const newTier = { href: "/admin/versand/neu", label: t.adminShippingPage.newTier };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminShippingPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl text-forest">{t.adminShippingPage.title}</h1>
|
||||
<Link href="/admin/versand/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminShippingPage.newTier}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
|
||||
|
||||
<AdminPageHeader title={t.adminShippingPage.title} intro={t.adminShippingPage.intro} action={newTier} />
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
@@ -41,28 +40,24 @@ export default async function AdminVersandPage() {
|
||||
? t.adminShippingPage.quantityFrom(tier.minQuantity)
|
||||
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
|
||||
</td>
|
||||
<td className="p-4">{formatPrice(tier.priceCents)}</td>
|
||||
<td className="p-4 tabular-nums">{formatPrice(tier.priceCents)}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Link href={`/admin/versand/${tier.id}`} className="text-forest underline">
|
||||
{t.adminShippingPage.edit}
|
||||
</Link>
|
||||
<AdminRowActions>
|
||||
<AdminActionLink href={`/admin/versand/${tier.id}`}>{t.adminShippingPage.edit}</AdminActionLink>
|
||||
<ConfirmForm
|
||||
action={deleteShippingTierAction}
|
||||
confirmMessage={t.adminShippingPage.deleteConfirm}
|
||||
>
|
||||
<input type="hidden" name="id" value={tier.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminShippingPage.delete}
|
||||
</button>
|
||||
<AdminActionButton tone="danger">{t.adminShippingPage.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tiers.length === 0 ? <p className="p-6 text-ink/65">{t.adminShippingPage.noTiersYet}</p> : null}
|
||||
{tiers.length === 0 ? <AdminEmptyState message={t.adminShippingPage.noTiersYet} action={newTier} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { LinkButton } from "@/components/ui";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
|
||||
// Gemeinsame Bausteine der Admin-Listen (Kopfzeile, Suche, Status-Badge,
|
||||
// Leerzustand, Zeilenaktionen). Operate-Modus: gleiche Stelle, gleiche Form
|
||||
// auf jeder Liste, damit man beim Wechseln nicht neu suchen muss.
|
||||
|
||||
export function AdminPageHeader({
|
||||
title,
|
||||
intro,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
intro?: ReactNode;
|
||||
/** Hauptaktion der Seite, z.B. "Neuer Gutschein". */
|
||||
action?: { href: string; label: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl text-forest">{title}</h1>
|
||||
{action ? (
|
||||
<LinkButton href={action.href} className="px-5 py-2.5">
|
||||
{action.label}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
{intro ? <p className="mt-2 max-w-2xl text-sm text-ink/70">{intro}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSearchForm({
|
||||
query,
|
||||
label,
|
||||
placeholder,
|
||||
submitLabel,
|
||||
resetHref,
|
||||
resetLabel,
|
||||
}: {
|
||||
query: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
submitLabel: string;
|
||||
resetHref: string;
|
||||
resetLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<form method="GET" role="search" className="mb-6 flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-ink/65">{label}</span>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder={placeholder}
|
||||
className="w-64 max-w-full border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
{query ? (
|
||||
<Link href={resetHref} className="link-draw py-2 text-sm text-ink/65">
|
||||
{resetLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminBadgeTone = "positive" | "pending" | "neutral" | "danger";
|
||||
|
||||
const BADGE_TONES: Record<AdminBadgeTone, string> = {
|
||||
positive: "border-forest/40 bg-forest/5 text-forest",
|
||||
pending: "border-honey/40 bg-honey/5 text-honey-dark",
|
||||
neutral: "border-ink/20 text-ink/70",
|
||||
danger: "border-red-200 bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
/** Status auf einen Blick — gleiche Form wie der Bestellstatus (OrderStatusBadge). */
|
||||
export function AdminBadge({
|
||||
tone,
|
||||
pulse = false,
|
||||
children,
|
||||
}: {
|
||||
tone: AdminBadgeTone;
|
||||
/** Wartet auf eine Handlung (z.B. Freigabe): Punkt pulsiert sanft. */
|
||||
pulse?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 whitespace-nowrap border px-2.5 py-1 text-xs tracking-wide-caps ${BADGE_TONES[tone]}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full bg-current ${pulse ? "motion-pulse animate-[wh-pulse_2s_ease-out_infinite]" : ""}`}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Leerzustand statt einer blassen Textzeile: sagt, was fehlt, und bietet den naechsten Schritt an. */
|
||||
export function AdminEmptyState({
|
||||
message,
|
||||
action,
|
||||
bordered = false,
|
||||
}: {
|
||||
message: string;
|
||||
action?: { href: string; label: string };
|
||||
/** Eigener Rahmen, wenn der Leerzustand nicht in einer Tabellenkarte steht. */
|
||||
bordered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-4 px-6 py-12 text-center ${bordered ? "border border-ink/10 bg-background" : ""}`}
|
||||
>
|
||||
<span className="relative inline-flex text-honey/60">
|
||||
<HexMark className="h-10 w-10" filled={false} />
|
||||
<HexMark className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 text-honey" />
|
||||
</span>
|
||||
<p className="max-w-sm text-sm text-ink/70">{message}</p>
|
||||
{action ? (
|
||||
<LinkButton href={action.href} variant="secondary" className="px-5 py-2.5">
|
||||
{action.label}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminRowActions({ children }: { children: ReactNode }) {
|
||||
return <div className="flex items-center justify-end gap-4 whitespace-nowrap">{children}</div>;
|
||||
}
|
||||
|
||||
export function AdminActionLink({ href, children }: { href: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link href={href} className="link-draw text-forest">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_TONES = {
|
||||
default: "text-forest",
|
||||
muted: "text-ink/70",
|
||||
danger: "text-red-700",
|
||||
} as const;
|
||||
|
||||
/** Absende-Knopf einer Zeilenaktion — sieht aus wie ein Link, ist aber ein Formular-Submit. */
|
||||
export function AdminActionButton({
|
||||
tone = "default",
|
||||
children,
|
||||
}: {
|
||||
tone?: keyof typeof ACTION_TONES;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button type="submit" className={`link-draw cursor-pointer ${ACTION_TONES[tone]}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Grund, warum eine Aktion fehlt — statt den Knopf wortlos wegzulassen. */
|
||||
export function AdminActionNote({ children }: { children: ReactNode }) {
|
||||
return <span className="text-xs text-ink/55">{children}</span>;
|
||||
}
|
||||
@@ -718,6 +718,7 @@ export const de: Dictionary = {
|
||||
unlimited: "unbegrenzt",
|
||||
statusActive: "Aktiv",
|
||||
statusInactive: "Inaktiv",
|
||||
statusExpired: "Abgelaufen",
|
||||
edit: "Bearbeiten",
|
||||
deleteConfirm: (code) => `Gutschein "${code}" wirklich löschen?`,
|
||||
delete: "Löschen",
|
||||
@@ -1136,6 +1137,7 @@ export const de: Dictionary = {
|
||||
statusInProgress: "In Bearbeitung",
|
||||
statusCompleted: "Abgeschlossen",
|
||||
statusRejected: "Abgelehnt",
|
||||
statusSelectLabel: "Status der Anfrage",
|
||||
save: "Speichern",
|
||||
packageLabel: "Paket",
|
||||
quantityLabel: "Menge",
|
||||
|
||||
@@ -706,6 +706,7 @@ export const en: Dictionary = {
|
||||
unlimited: "unlimited",
|
||||
statusActive: "Active",
|
||||
statusInactive: "Inactive",
|
||||
statusExpired: "Expired",
|
||||
edit: "Edit",
|
||||
deleteConfirm: (code) => `Really delete voucher "${code}"?`,
|
||||
delete: "Delete",
|
||||
@@ -1122,6 +1123,7 @@ export const en: Dictionary = {
|
||||
statusInProgress: "In progress",
|
||||
statusCompleted: "Completed",
|
||||
statusRejected: "Rejected",
|
||||
statusSelectLabel: "Inquiry status",
|
||||
save: "Save",
|
||||
packageLabel: "Package",
|
||||
quantityLabel: "Quantity",
|
||||
|
||||
@@ -397,6 +397,7 @@ export interface Dictionary {
|
||||
unlimited: string;
|
||||
statusActive: string;
|
||||
statusInactive: string;
|
||||
statusExpired: string;
|
||||
edit: string;
|
||||
deleteConfirm: (code: string) => string;
|
||||
delete: string;
|
||||
@@ -803,6 +804,7 @@ export interface Dictionary {
|
||||
statusInProgress: string;
|
||||
statusCompleted: string;
|
||||
statusRejected: string;
|
||||
statusSelectLabel: string;
|
||||
save: string;
|
||||
packageLabel: string;
|
||||
quantityLabel: string;
|
||||
|
||||
Reference in new issue
Block a user