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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 20:51:50 +02:00
1 parent 95b4b5953d
commit a09057d960
12 files changed
+425 -285

No files matched your search

+37 -26
View File
@@ -1,12 +1,25 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import {
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminSearchForm,
type AdminBadgeTone,
} from "@/components/admin-list";
import { updateBusinessInquiryStatusAction } from "../actions"; import { updateBusinessInquiryStatusAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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">) { export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
@@ -35,46 +48,40 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} /> <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"> <AdminSearchForm
<label className="text-sm"> query={query}
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span> label={t.adminInquiriesPage.searchLabel}
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminInquiriesPage.searchPlaceholder} 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"> <div className="space-y-4">
{inquiries.map((inquiry) => ( {inquiries.map((inquiry) => (
<div key={inquiry.id} className="bg-background border border-ink/10 p-6"> <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 className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<p className="text-forest">{inquiry.companyName}</p> <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"> <p className="text-sm text-ink/70">
{inquiry.contactPerson} · {inquiry.email} {inquiry.contactPerson} · {inquiry.email}
{inquiry.phone ? ` · ${inquiry.phone}` : ""} {inquiry.phone ? ` · ${inquiry.phone}` : ""}
</p> </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> </div>
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2"> <form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
<input type="hidden" name="id" value={inquiry.id} /> <input type="hidden" name="id" value={inquiry.id} />
<select <select
name="status" name="status"
defaultValue={inquiry.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]) => ( {Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}> <option key={value} value={value}>
@@ -82,7 +89,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
</option> </option>
))} ))}
</select> </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} {t.adminInquiriesPage.save}
</button> </button>
</form> </form>
@@ -107,9 +117,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
</div> </div>
))} ))}
{inquiries.length === 0 ? ( {inquiries.length === 0 ? (
<p className="text-ink/65"> <AdminEmptyState
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet} bordered
</p> message={query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+4 -4
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -16,8 +17,7 @@ export default async function AdminAuditLogPage() {
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} /> <BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1> <AdminPageHeader title={t.adminAuditLogPage.title} intro={t.adminAuditLogPage.intro(RECENT_LIMIT)} />
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -33,7 +33,7 @@ export default async function AdminAuditLogPage() {
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{entries.map((entry) => ( {entries.map((entry) => (
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]"> <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">{entry.adminEmail}</td>
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</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"> <td className="p-4 whitespace-nowrap text-ink/70">
@@ -45,7 +45,7 @@ export default async function AdminAuditLogPage() {
))} ))}
</tbody> </tbody>
</table> </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>
</div> </div>
); );
+48 -51
View File
@@ -1,10 +1,18 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
AdminSearchForm,
} from "@/components/admin-list";
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions"; import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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 ( return (
<div> <div>
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminUsersPage.title} action={newUser} />
<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>
<AdminSearchForm
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> query={query}
<label className="text-sm"> label={t.adminUsersPage.searchLabel}
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminUsersPage.searchPlaceholder} 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"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <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} {isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
</td> </td>
<td className="p-4">{customer.email}</td> <td className="p-4">{customer.email}</td>
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
<td className="p-4"> <td className="p-4">
{customer.blockedAt ? ( {customer.role === "ADMIN" ? (
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span> <AdminBadge tone="neutral">{roleLabels.ADMIN}</AdminBadge>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
) : ( ) : (
<span className="text-forest">{t.adminUsersPage.statusActive}</span> (roleLabels[customer.role] ?? customer.role)
)} )}
</td> </td>
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
<td className="p-4">{formatDate(customer.createdAt)}</td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> {customer.blockedAt ? (
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline"> <AdminBadge tone="danger">{t.adminUsersPage.statusBlocked}</AdminBadge>
{t.adminUsersPage.edit} ) : isTemporarilyLocked ? (
</Link> <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 ? ( {!isSelf ? (
customer.blockedAt ? ( customer.blockedAt ? (
<form action={unblockCustomerAction}> <form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline"> <AdminActionButton>{t.adminUsersPage.unblock}</AdminActionButton>
{t.adminUsersPage.unblock}
</button>
</form> </form>
) : ( ) : (
<form action={blockCustomerAction}> <form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline"> <AdminActionButton tone="muted">{t.adminUsersPage.block}</AdminActionButton>
{t.adminUsersPage.block}
</button>
</form> </form>
) )
) : null} ) : null}
@@ -129,12 +127,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)} confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
> >
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminUsersPage.delete}</AdminActionButton>
{t.adminUsersPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : null} ) : null}
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
); );
@@ -142,9 +138,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
</tbody> </tbody>
</table> </table>
{customers.length === 0 ? ( {customers.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet} message={query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
</p> action={query ? undefined : newUser}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+31 -36
View File
@@ -3,6 +3,15 @@ import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; 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 { insensitiveContains } from "@/lib/prisma-search";
import { approveReviewAction, deleteReviewAction } from "../actions"; import { approveReviewAction, deleteReviewAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -34,28 +43,16 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} /> <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"> <AdminSearchForm
<label className="text-sm"> query={query}
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span> label={t.adminReviewsPage.searchLabel}
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminReviewsPage.searchPlaceholder} 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"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -74,34 +71,34 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
{reviews.map((review) => ( {reviews.map((review) => (
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4"> <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} {review.product.name}
</Link> </Link>
</td> </td>
<td className="p-4"> <td className="p-4">
{review.customer.firstName} {review.customer.lastName} {review.customer.firstName} {review.customer.lastName}
</td> </td>
<td className="p-4 text-honey-dark whitespace-nowrap"> <td className="p-4 whitespace-nowrap">
{"★".repeat(review.rating)} <StarRating value={review.rating} />
{"☆".repeat(5 - review.rating)} <span className="sr-only">{review.rating} / 5</span>
</td> </td>
<td className="p-4 max-w-xs">{review.comment ?? "—"}</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 whitespace-nowrap">{formatDate(review.createdAt)}</td>
<td className="p-4"> <td className="p-4">
{review.isApproved ? ( {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>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> <AdminRowActions>
{!review.isApproved ? ( {!review.isApproved ? (
<form action={approveReviewAction}> <form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} /> <input type="hidden" name="id" value={review.id} />
<button type="submit" className="text-forest underline"> <AdminActionButton>{t.adminReviewsPage.approve}</AdminActionButton>
{t.adminReviewsPage.approve}
</button>
</form> </form>
) : null} ) : null}
<ConfirmForm <ConfirmForm
@@ -109,20 +106,18 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
confirmMessage={t.adminReviewsPage.deleteConfirm} confirmMessage={t.adminReviewsPage.deleteConfirm}
> >
<input type="hidden" name="id" value={review.id} /> <input type="hidden" name="id" value={review.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminReviewsPage.delete}</AdminActionButton>
{t.adminReviewsPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{reviews.length === 0 ? ( {reviews.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet} message={query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
</p> />
) : null} ) : null}
</div> </div>
</div> </div>
+45 -50
View File
@@ -1,8 +1,17 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; 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 { createBatchAction, deleteBatchAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -20,7 +29,7 @@ export default async function AdminBatchesPage() {
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} /> <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"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <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"> <td className="p-4">
{batch.harvest.type} {batch.harvest.year} {batch.harvest.type} {batch.harvest.year}
</td> </td>
<td className="p-4">{formatDate(batch.fillDate)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.fillDate)}</td>
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.bestBeforeDate)}</td>
<td className="p-4">{batch.glassCount}</td> <td className="p-4 tabular-nums">{batch.glassCount}</td>
<td className="p-4">{batch.remainingGlasses}</td> <td className={`p-4 tabular-nums ${batch.remainingGlasses === 0 ? "text-ink/55" : ""}`}>
<td className="p-4 whitespace-nowrap"> {batch.remainingGlasses}
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline mr-4"> </td>
{t.adminBatchesPage.edit} <td className="p-4">
</Link> <AdminRowActions>
<AdminActionLink href={`/admin/chargen/${batch.id}`}>{t.adminBatchesPage.edit}</AdminActionLink>
{batch._count.products === 0 && batch._count.orderItems === 0 ? ( {batch._count.products === 0 && batch._count.orderItems === 0 ? (
<ConfirmForm <ConfirmForm
action={deleteBatchAction} action={deleteBatchAction}
confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)} confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)}
className="inline"
> >
<input type="hidden" name="id" value={batch.id} /> <input type="hidden" name="id" value={batch.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminBatchesPage.delete}</AdminActionButton>
{t.adminBatchesPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : ( ) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da. // 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> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl"> <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"> <form action={createBatchAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
<input
name="code" name="code"
required label={t.adminBatchesPage.codeLabel}
placeholder={t.adminBatchesPage.codePlaceholder} placeholder={t.adminBatchesPage.codePlaceholder}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" required
/> />
</label> <AdminSelect name="harvestId" label={t.adminBatchesPage.harvestLabel}>
<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">
{harvests.map((h) => ( {harvests.map((h) => (
<option key={h.id} value={h.id}> <option key={h.id} value={h.id}>
{h.type} {h.year} {h.type} {h.year}
</option> </option>
))} ))}
</select> </AdminSelect>
</label>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField name="fillDate" label={t.adminBatchesPage.fillDateLabel} type="date" required />
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span> <AdminField name="bestBeforeDate" label={t.adminBatchesPage.bestBeforeDateLabel} type="date" required />
<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> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField name="glassCount" label={t.adminBatchesPage.glassCountLabel} type="number" required />
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span> <AdminField
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> name="remainingGlasses"
</label> label={t.adminBatchesPage.remainingGlassesLabel}
<label className="block text-sm"> type="number"
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span> required
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> />
</label> </div>
<div className="flex justify-end border-t border-ink/10 pt-4">
<Button type="submit">{t.adminBatchesPage.create}</Button>
</div> </div>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchesPage.create}
</button>
</form> </form>
</div> </div>
</div> </div>
+42 -77
View File
@@ -1,8 +1,18 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; 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 { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel"; import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -19,7 +29,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} /> <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} /> <ApiCoreSyncResult searchParams={searchParams} />
@@ -41,74 +51,64 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
{harvests.map((harvest) => ( {harvests.map((harvest) => (
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]"> <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 text-forest">{harvest.type}</td>
<td className="p-4">{harvest.year}</td> <td className="p-4 tabular-nums">{harvest.year}</td>
<td className="p-4">{formatDate(harvest.harvestDate)}</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.location}</td>
<td className="p-4">{harvest._count.batches}</td> <td className="p-4 tabular-nums">{harvest._count.batches}</td>
<td className="p-4">{harvest._count.products}</td> <td className="p-4 tabular-nums">{harvest._count.products}</td>
<td className="p-4"> <td className="p-4">
<span {harvest.isPublished ? (
className={ <AdminBadge tone="positive">{t.adminHarvestsPage.statusPublished}</AdminBadge>
harvest.isPublished ) : (
? "text-forest bg-forest/10 px-2 py-1 text-xs" <AdminBadge tone="pending">{t.adminHarvestsPage.statusDraft}</AdminBadge>
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs" )}
}
>
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
</span>
</td> </td>
<td className="p-4 whitespace-nowrap"> <td className="p-4">
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4"> <AdminRowActions>
{t.adminHarvestsPage.edit} <AdminActionLink href={`/admin/ernten/${harvest.id}`}>{t.adminHarvestsPage.edit}</AdminActionLink>
</Link> <form action={toggleHarvestPublishedAction}>
<form action={toggleHarvestPublishedAction} className="inline">
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />} {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} {harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
</button> </AdminActionButton>
</form> </form>
{harvest._count.batches === 0 && harvest._count.products === 0 ? ( {harvest._count.batches === 0 && harvest._count.products === 0 ? (
<ConfirmForm <ConfirmForm
action={deleteHarvestAction} action={deleteHarvestAction}
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)} confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
className="inline"
> >
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminHarvestsPage.delete}</AdminActionButton>
{t.adminHarvestsPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : ( ) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da. // 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> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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>
<div className="mt-10 grid md:grid-cols-2 gap-6"> <div className="mt-10 grid md:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-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"> <form action={createHarvestAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field name="type" label={t.adminHarvestsPage.typeLabel} required /> <AdminField name="type" label={t.adminHarvestsPage.typeLabel} required />
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" 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> </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> </form>
</div> </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>
);
}
+22 -21
View File
@@ -1,8 +1,15 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { deleteDiscountCodeAction } from "../actions"; import { deleteDiscountCodeAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -10,17 +17,13 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinePage() { export default async function AdminGutscheinePage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } }); const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
const now = new Date();
const newVoucher = { href: "/admin/gutscheine/neu", label: t.adminVouchersPage.newVoucher };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminVouchersPage.title} action={newVoucher} />
<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>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -45,7 +48,7 @@ export default async function AdminGutscheinePage() {
</td> </td>
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</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">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
<td className="p-4"> <td className="p-4 tabular-nums">
{code.usedCount} {code.usedCount}
{code.maxUses !== null ? ` / ${code.maxUses}` : ""} {code.maxUses !== null ? ` / ${code.maxUses}` : ""}
</td> </td>
@@ -54,33 +57,31 @@ export default async function AdminGutscheinePage() {
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited} {code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
</td> </td>
<td className="p-4"> <td className="p-4">
{code.isActive ? ( {!code.isActive ? (
<span className="text-forest">{t.adminVouchersPage.statusActive}</span> <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>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> <AdminRowActions>
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline"> <AdminActionLink href={`/admin/gutscheine/${code.id}`}>{t.adminVouchersPage.edit}</AdminActionLink>
{t.adminVouchersPage.edit}
</Link>
<ConfirmForm <ConfirmForm
action={deleteDiscountCodeAction} action={deleteDiscountCodeAction}
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)} confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
> >
<input type="hidden" name="id" value={code.id} /> <input type="hidden" name="id" value={code.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminVouchersPage.delete}</AdminActionButton>
{t.adminVouchersPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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>
</div> </div>
); );
+15 -20
View File
@@ -1,8 +1,14 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { deleteShippingTierAction } from "../actions"; import { deleteShippingTierAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -10,19 +16,12 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandPage() { export default async function AdminVersandPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } }); const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
const newTier = { href: "/admin/versand/neu", label: t.adminShippingPage.newTier };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminShippingPage.backToDashboard} /> <BackLink href="/admin" label={t.adminShippingPage.backToDashboard} />
<div className="flex items-center justify-between mb-4"> <AdminPageHeader title={t.adminShippingPage.title} intro={t.adminShippingPage.intro} action={newTier} />
<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>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -41,28 +40,24 @@ export default async function AdminVersandPage() {
? t.adminShippingPage.quantityFrom(tier.minQuantity) ? t.adminShippingPage.quantityFrom(tier.minQuantity)
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)} : t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
</td> </td>
<td className="p-4">{formatPrice(tier.priceCents)}</td> <td className="p-4 tabular-nums">{formatPrice(tier.priceCents)}</td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> <AdminRowActions>
<Link href={`/admin/versand/${tier.id}`} className="text-forest underline"> <AdminActionLink href={`/admin/versand/${tier.id}`}>{t.adminShippingPage.edit}</AdminActionLink>
{t.adminShippingPage.edit}
</Link>
<ConfirmForm <ConfirmForm
action={deleteShippingTierAction} action={deleteShippingTierAction}
confirmMessage={t.adminShippingPage.deleteConfirm} confirmMessage={t.adminShippingPage.deleteConfirm}
> >
<input type="hidden" name="id" value={tier.id} /> <input type="hidden" name="id" value={tier.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminShippingPage.delete}</AdminActionButton>
{t.adminShippingPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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>
</div> </div>
); );
+175
View File
@@ -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>;
}
+2
View File
@@ -718,6 +718,7 @@ export const de: Dictionary = {
unlimited: "unbegrenzt", unlimited: "unbegrenzt",
statusActive: "Aktiv", statusActive: "Aktiv",
statusInactive: "Inaktiv", statusInactive: "Inaktiv",
statusExpired: "Abgelaufen",
edit: "Bearbeiten", edit: "Bearbeiten",
deleteConfirm: (code) => `Gutschein "${code}" wirklich löschen?`, deleteConfirm: (code) => `Gutschein "${code}" wirklich löschen?`,
delete: "Löschen", delete: "Löschen",
@@ -1136,6 +1137,7 @@ export const de: Dictionary = {
statusInProgress: "In Bearbeitung", statusInProgress: "In Bearbeitung",
statusCompleted: "Abgeschlossen", statusCompleted: "Abgeschlossen",
statusRejected: "Abgelehnt", statusRejected: "Abgelehnt",
statusSelectLabel: "Status der Anfrage",
save: "Speichern", save: "Speichern",
packageLabel: "Paket", packageLabel: "Paket",
quantityLabel: "Menge", quantityLabel: "Menge",
+2
View File
@@ -706,6 +706,7 @@ export const en: Dictionary = {
unlimited: "unlimited", unlimited: "unlimited",
statusActive: "Active", statusActive: "Active",
statusInactive: "Inactive", statusInactive: "Inactive",
statusExpired: "Expired",
edit: "Edit", edit: "Edit",
deleteConfirm: (code) => `Really delete voucher "${code}"?`, deleteConfirm: (code) => `Really delete voucher "${code}"?`,
delete: "Delete", delete: "Delete",
@@ -1122,6 +1123,7 @@ export const en: Dictionary = {
statusInProgress: "In progress", statusInProgress: "In progress",
statusCompleted: "Completed", statusCompleted: "Completed",
statusRejected: "Rejected", statusRejected: "Rejected",
statusSelectLabel: "Inquiry status",
save: "Save", save: "Save",
packageLabel: "Package", packageLabel: "Package",
quantityLabel: "Quantity", quantityLabel: "Quantity",
+2
View File
@@ -397,6 +397,7 @@ export interface Dictionary {
unlimited: string; unlimited: string;
statusActive: string; statusActive: string;
statusInactive: string; statusInactive: string;
statusExpired: string;
edit: string; edit: string;
deleteConfirm: (code: string) => string; deleteConfirm: (code: string) => string;
delete: string; delete: string;
@@ -803,6 +804,7 @@ export interface Dictionary {
statusInProgress: string; statusInProgress: string;
statusCompleted: string; statusCompleted: string;
statusRejected: string; statusRejected: string;
statusSelectLabel: string;
save: string; save: string;
packageLabel: string; packageLabel: string;
quantityLabel: string; quantityLabel: string;