mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
"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;
|