Files
guilan-ace-frontend/src/views/AdminCoupons.tsx
Amirhossein Khalili fc94ceb9f5
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
feat(ui): confirm destructive admin actions
2026-06-14 09:10:32 +03:30

251 lines
12 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 * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit3, Plus, Trash2 } from "lucide-react";
import AdminDateTimeField from "@/components/AdminDateTimeField";
import ConfirmAction from "@/components/ConfirmAction";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { DiscountCodeSchema, DiscountCodeWriteSchema } from "@/lib/types";
import { api } from "@/lib/api";
import { formatNumberPersian, resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
const PAGE_SIZE = 20;
const emptyForm: DiscountCodeWriteSchema = {
code: "",
type: "percent",
value: 0,
max_discount: null,
is_active: true,
starts_at: null,
ends_at: null,
usage_limit_total: null,
usage_limit_per_user: null,
min_amount: null,
applicable_event_ids: [],
};
export default function AdminCoupons() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [page, setPage] = React.useState(1);
const [editing, setEditing] = React.useState<DiscountCodeSchema | null>(null);
const [open, setOpen] = React.useState(false);
const [form, setForm] = React.useState<DiscountCodeWriteSchema>(emptyForm);
React.useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
const query = useQuery({
queryKey: ["admin", "coupons", debouncedSearch, page],
queryFn: () => api.listDiscountCodes({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
});
const saveMutation = useMutation({
mutationFn: () => (editing ? api.updateDiscountCode(editing.id, form) : api.createDiscountCode(form)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "coupons"] });
setOpen(false);
toast({ title: "کد تخفیف ذخیره شد", variant: "success" });
},
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteDiscountCode(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "coupons"] });
toast({ title: "کد تخفیف حذف شد", variant: "success" });
},
onError: (error) => toast({ title: "خطا", description: resolveErrorMessage(error), variant: "destructive" }),
});
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setOpen(true);
};
const openEdit = (item: DiscountCodeSchema) => {
setEditing(item);
setForm({
code: item.code,
type: item.type,
value: item.value,
max_discount: item.max_discount,
is_active: item.is_active,
starts_at: item.starts_at,
ends_at: item.ends_at,
usage_limit_total: item.usage_limit_total,
usage_limit_per_user: item.usage_limit_per_user,
min_amount: item.min_amount,
applicable_event_ids: item.applicable_event_ids,
});
setOpen(true);
};
const items = query.data?.results ?? [];
const count = query.data?.count ?? 0;
const hasMore = page * PAGE_SIZE < count;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-semibold">کدهای تخفیف</h2>
<p className="mt-1 text-sm text-muted-foreground">مدیریت کدهای تخفیف رویدادها</p>
</div>
<Button onClick={openCreate}>
<Plus className="ml-2 h-4 w-4" />
افزودن
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>فهرست کدها</CardTitle>
<CardDescription>جستجو و مدیریت وضعیت کدهای تخفیف</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="جستجو بر اساس کد..."
className="max-w-md"
/>
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-right">کد</th>
<th className="px-4 py-3 text-right">نوع</th>
<th className="px-4 py-3 text-right">مقدار</th>
<th className="px-4 py-3 text-right">استفاده</th>
<th className="px-4 py-3 text-right">وضعیت</th>
<th className="px-4 py-3 text-left"></th>
</tr>
</thead>
<tbody>
{query.isLoading ? (
<tr><td colSpan={6} className="px-4 py-6 text-center text-muted-foreground">در حال بارگذاری...</td></tr>
) : items.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-6 text-center text-muted-foreground">کدی یافت نشد.</td></tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-t hover:bg-muted/40">
<td className="px-4 py-3 font-mono font-bold">{item.code}</td>
<td className="px-4 py-3">{item.type === "percent" ? "درصدی" : "مبلغ ثابت"}</td>
<td className="px-4 py-3">{formatNumberPersian(item.value)}</td>
<td className="px-4 py-3">{formatNumberPersian(item.usage_count)}</td>
<td className="px-4 py-3">
<Badge variant={item.is_active ? "default" : "outline"}>{item.is_active ? "فعال" : "غیرفعال"}</Badge>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
<Edit3 className="h-4 w-4" />
</Button>
<ConfirmAction
title="حذف کد تخفیف"
description={`آیا از حذف کد «${item.code}» مطمئن هستید؟ این کد دیگر در لیست‌های عادی نمایش داده نمی‌شود.`}
onConfirm={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
trigger={
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" disabled={deleteMutation.isPending} aria-label="حذف">
<Trash2 className="h-4 w-4" />
</Button>
}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>صفحه {formatNumberPersian(page)} از {formatNumberPersian(Math.max(1, Math.ceil(count / PAGE_SIZE)))}</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>قبلی</Button>
<Button size="sm" variant="outline" disabled={!hasMore} onClick={() => setPage((current) => current + 1)}>بعدی</Button>
</div>
</div>
</CardContent>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle>{editing ? "ویرایش کد تخفیف" : "افزودن کد تخفیف"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 md:grid-cols-2">
<div className="space-y-2">
<Label>کد</Label>
<Input dir="ltr" value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>نوع</Label>
<Select value={form.type} onValueChange={(value) => setForm((current) => ({ ...current, type: value as "percent" | "fixed" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="percent">درصدی</SelectItem>
<SelectItem value="fixed">مبلغ ثابت</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>مقدار</Label>
<Input type="number" value={form.value} onChange={(event) => setForm((current) => ({ ...current, value: Number(event.target.value) }))} />
</div>
<div className="space-y-2">
<Label>حداکثر تخفیف</Label>
<Input type="number" value={form.max_discount ?? ""} onChange={(event) => setForm((current) => ({ ...current, max_discount: event.target.value ? Number(event.target.value) : null }))} />
</div>
<div className="space-y-2">
<Label>حداقل مبلغ</Label>
<Input type="number" value={form.min_amount ?? ""} onChange={(event) => setForm((current) => ({ ...current, min_amount: event.target.value ? Number(event.target.value) : null }))} />
</div>
<div className="space-y-2">
<Label>محدودیت کل</Label>
<Input type="number" value={form.usage_limit_total ?? ""} onChange={(event) => setForm((current) => ({ ...current, usage_limit_total: event.target.value ? Number(event.target.value) : null }))} />
</div>
<div className="space-y-2">
<Label>محدودیت هر کاربر</Label>
<Input type="number" value={form.usage_limit_per_user ?? ""} onChange={(event) => setForm((current) => ({ ...current, usage_limit_per_user: event.target.value ? Number(event.target.value) : null }))} />
</div>
<div className="flex items-center justify-between rounded-xl border px-3 py-2">
<Label>فعال</Label>
<Switch checked={form.is_active ?? true} onCheckedChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))} />
</div>
<AdminDateTimeField label="شروع اعتبار" value={form.starts_at} onChange={(value) => setForm((current) => ({ ...current, starts_at: value }))} />
<AdminDateTimeField label="پایان اعتبار" value={form.ends_at} onChange={(value) => setForm((current) => ({ ...current, ends_at: value }))} />
</div>
<DialogFooter className="gap-2 sm:justify-start">
<Button variant="outline" onClick={() => setOpen(false)}>انصراف</Button>
<Button disabled={saveMutation.isPending || !form.code.trim() || form.value <= 0} onClick={() => saveMutation.mutate()}>
ذخیره
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}