Kuma/src/modules/settings/ui/components/DiscountCodeList.tsx
2026-09-13 18:45:46 +02:00

398 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import { Plus, Trash2, Tag, Search, Calendar, Percent, Coins, Layers } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
import type { DiscountCodeDTO } from "../../actions/discount-actions";
interface DiscountCodeListProps {
codes: DiscountCodeDTO[];
courses: { id: string; name: string }[];
onCreate: (input: {
code: string;
type: "percent" | "fixed";
value: number;
scope: "all_courses" | "specific_courses";
courseIds: string[];
maxRedemptions: number | null;
startsAt: Date | null;
expiresAt: Date | null;
isActive: boolean;
}) => Promise<boolean>;
onDeactivate: (discountCodeId: string) => Promise<boolean>;
isSaving: boolean;
}
const emptyForm = {
code: "",
type: "percent" as "percent" | "fixed",
value: 10,
scope: "all_courses" as "all_courses" | "specific_courses",
courseIds: [] as string[],
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
startsAt: "" as string,
expiresAt: "" as string,
};
type StatusFilter = "all" | "active" | "inactive" | "expired";
function formatDate(date: Date | null): string {
if (!date) return "—";
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(date);
}
function isExpired(code: DiscountCodeDTO): boolean {
return !!code.expiresAt && new Date(code.expiresAt) < new Date();
}
// Rein visueller Ableitungs-Status fürs Filtern/Badges - ändert nichts an
// DiscountCode.isActive selbst (das bleibt der manuelle An/Aus-Schalter,
// siehe deactivateDiscountCode). "expired" hat Vorrang vor "active", weil ein
// abgelaufener Code trotz isActive=true nicht mehr einlösbar ist.
function getDisplayStatus(code: DiscountCodeDTO): "active" | "inactive" | "expired" {
if (!code.isActive) return "inactive";
if (isExpired(code)) return "expired";
return "active";
}
const STATUS_STYLES: Record<
"active" | "inactive" | "expired",
{ label: string; bg: string; text: string }
> = {
active: { label: "Aktiv", bg: "bg-green-100", text: "text-green-700" },
inactive: { label: "Deaktiviert", bg: "bg-gray-200", text: "text-gray-600" },
expired: { label: "Abgelaufen", bg: "bg-orange-100", text: "text-orange-700" },
};
const DiscountCodeList = ({
codes,
courses,
onCreate,
onDeactivate,
isSaving,
}: DiscountCodeListProps) => {
const [form, setForm] = useState(emptyForm);
const [isCreating, setIsCreating] = useState(false);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const courseOptions = courses.map((c) => ({ value: c.id, label: c.name }));
const courseNameById = useMemo(() => new Map(courses.map((c) => [c.id, c.name])), [courses]);
const filteredCodes = useMemo(() => {
const query = search.trim().toUpperCase();
return codes.filter((c) => {
if (query && !c.code.includes(query)) return false;
if (statusFilter !== "all" && getDisplayStatus(c) !== statusFilter) return false;
return true;
});
}, [codes, search, statusFilter]);
const handleCreate = async () => {
const success = await onCreate({
code: form.code,
type: form.type,
value: form.value,
scope: form.scope,
courseIds: form.courseIds,
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
startsAt: form.startsAt ? new Date(form.startsAt) : null,
expiresAt: form.expiresAt ? new Date(form.expiresAt) : null,
isActive: true,
});
if (success) {
setForm(emptyForm);
setIsCreating(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{codes.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:w-60">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none text-gray-400"
size={16}
/>
<Input
placeholder="Code suchen…"
className="pl-9 bg-white/60 backdrop-blur-xl border-white/40 placeholder:text-gray-400"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="active">Aktiv</SelectItem>
<SelectItem value="inactive">Deaktiviert</SelectItem>
<SelectItem value="expired">Abgelaufen</SelectItem>
</SelectContent>
</Select>
</div>
)}
{!isCreating && (
<Button size="sm" onClick={() => setIsCreating(true)} className="sm:ml-auto shrink-0">
<Plus size={14} className="mr-1.5" />
Rabattcode erstellen
</Button>
)}
</div>
{isCreating && (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Code</label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
placeholder="z.B. SOMMER25"
/>
</div>
<div className="flex gap-3">
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Typ</label>
<Select
value={form.type}
onValueChange={(v) => setForm((f) => ({ ...f, type: v as "percent" | "fixed" }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percent">Prozent</SelectItem>
<SelectItem value="fixed">Fixbetrag</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">
{form.type === "percent" ? "Rabatt in %" : "Rabatt in €"}
</label>
<Input
type="number"
min={form.type === "percent" ? 1 : 0.01}
max={form.type === "percent" ? 100 : undefined}
step={form.type === "percent" ? 1 : 0.01}
value={form.type === "percent" ? form.value : form.value / 100}
onChange={(e) => {
const raw = Number(e.target.value);
const cents = form.type === "percent" ? raw : Math.round(raw * 100);
setForm((f) => ({ ...f, value: Number.isFinite(cents) ? cents : 0 }));
}}
/>
</div>
</div>
<div className="flex gap-2 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-2">
<Button
type="button"
variant={form.scope === "all_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
>
Alle Kurse
</Button>
<Button
type="button"
variant={form.scope === "specific_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
>
Bestimmte Kurse
</Button>
</div>
{form.scope === "specific_courses" && (
<MultiSelectDropdown
label="Kurse"
selected={form.courseIds}
onSelect={(values) => setForm((f) => ({ ...f, courseIds: values }))}
options={courseOptions}
/>
)}
<div className="flex flex-col gap-1 max-w-xs">
<label className="text-xs text-gray-500">
Maximale Einlösungen (leer = unbegrenzt)
</label>
<Input
type="number"
min={1}
value={form.maxRedemptions}
onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))}
placeholder="z.B. 50"
/>
</div>
<div className="flex gap-3">
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Gültig ab (optional)</label>
<Input
type="date"
value={form.startsAt}
onChange={(e) => setForm((f) => ({ ...f, startsAt: e.target.value }))}
/>
</div>
<div className="flex-1 flex flex-col gap-1">
<label className="text-xs text-gray-500">Gültig bis (optional)</label>
<Input
type="date"
value={form.expiresAt}
onChange={(e) => setForm((f) => ({ ...f, expiresAt: e.target.value }))}
/>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleCreate}
disabled={isSaving || form.code.trim().length < 3}
>
{isSaving ? "Speichert…" : "Erstellen"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsCreating(false);
setForm(emptyForm);
}}
>
Abbrechen
</Button>
</div>
</div>
)}
{codes.length === 0 ? (
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
Noch keine Rabattcodes angelegt.
</p>
) : filteredCodes.length === 0 ? (
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
Keine Rabattcodes gefunden.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filteredCodes.map((c) => {
const status = getDisplayStatus(c);
const statusStyle = STATUS_STYLES[status];
const redemptionPercent =
c.maxRedemptions !== null
? Math.min(100, Math.round((c.redeemedCount / c.maxRedemptions) * 100))
: null;
return (
<div
key={c.id}
className={`relative border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3 transition-opacity ${
status !== "active" ? "opacity-70" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 flex items-center justify-center size-9 rounded-lg bg-blue-50 text-blue-600">
<Tag size={16} />
</div>
<div className="min-w-0">
<p className="text-base font-bold text-gray-900 font-mono tracking-wide truncate">
{c.code}
</p>
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full mt-0.5 ${statusStyle.bg} ${statusStyle.text}`}
>
{statusStyle.label}
</span>
</div>
</div>
{c.isActive && (
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:bg-red-50 hover:text-red-600 shrink-0"
onClick={() => onDeactivate(c.id)}
disabled={isSaving}
title="Deaktivieren"
>
<Trash2 size={16} />
</Button>
)}
</div>
<div className="flex items-center gap-1.5 text-2xl font-bold text-gray-900">
{c.type === "percent" ? (
<Percent size={18} className="text-gray-400" />
) : (
<Coins size={18} className="text-gray-400" />
)}
{c.type === "percent" ? `${c.value}%` : `${(c.value / 100).toFixed(2)} €`}
<span className="text-xs font-normal text-gray-400 ml-1">Rabatt</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Layers size={13} className="shrink-0 text-gray-400" />
{c.scope === "all_courses" ? (
<span>Gilt für alle Kurse</span>
) : (
<span className="truncate">
Gilt für:{" "}
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
</span>
)}
</div>
{(c.startsAt || c.expiresAt) && (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Calendar size={13} className="shrink-0 text-gray-400" />
<span>
{formatDate(c.startsAt)} – {formatDate(c.expiresAt)}
</span>
</div>
)}
<div className="space-y-1 pt-1">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Eingelöst</span>
<span className="font-medium text-gray-700">
{c.redeemedCount}
{c.maxRedemptions !== null ? ` / ${c.maxRedemptions}` : " (unbegrenzt)"}
</span>
</div>
{redemptionPercent !== null && (
<div className="h-1.5 w-full rounded-full bg-gray-200 overflow-hidden">
<div
className={`h-full rounded-full ${redemptionPercent >= 100 ? "bg-orange-400" : "bg-blue-500"}`}
style={{ width: `${redemptionPercent}%` }}
/>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
export default DiscountCodeList;