Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs. Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Button,
|
||
Dialog,
|
||
FormField,
|
||
Input,
|
||
PageHeader,
|
||
Select,
|
||
EmptyState,
|
||
} from "@/components/ds";
|
||
import { loyaltyApi } from "@/modules/loyalty/services/loyalty-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { LoyaltyBreadcrumbs } from "@/modules/loyalty/components/LoyaltyBreadcrumbs";
|
||
import { LoyaltyPageLoader, LoyaltyPageError } from "@/modules/loyalty/pages/shared";
|
||
import { LoyaltyTablePage, LoyaltyStatusChip } from "@/modules/loyalty/design-system";
|
||
|
||
export default function PointTransactionsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const [accountId, setAccountId] = useState("");
|
||
const [active, setActive] = useState("");
|
||
const [op, setOp] = useState<"earn" | "redeem" | "adjust" | "expire" | null>(null);
|
||
const [amount, setAmount] = useState("10");
|
||
const [reason, setReason] = useState("");
|
||
const qc = useQueryClient();
|
||
|
||
const accountsQ = useQuery({
|
||
queryKey: ["loyalty", tenantId, "point-accounts"],
|
||
queryFn: () => loyaltyApi.pointAccounts.list(tenantId!, { page: 1, page_size: 500 }),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const balanceQ = useQuery({
|
||
queryKey: ["loyalty", tenantId, "point-balance", active],
|
||
queryFn: () => loyaltyApi.pointAccounts.balance(tenantId!, active),
|
||
enabled: !!tenantId && !!active,
|
||
});
|
||
|
||
const ledgerQ = useQuery({
|
||
queryKey: ["loyalty", tenantId, "point-ledger", active],
|
||
queryFn: () => loyaltyApi.pointAccounts.ledger(tenantId!, active, { offset: 0, limit: 100 }),
|
||
enabled: !!tenantId && !!active,
|
||
});
|
||
|
||
const mut = useMutation({
|
||
mutationFn: async () => {
|
||
const body = { amount: Number(amount), reason: reason || undefined };
|
||
if (op === "earn") return loyaltyApi.pointAccounts.earn(tenantId!, active, body);
|
||
if (op === "redeem") return loyaltyApi.pointAccounts.redeem(tenantId!, active, body);
|
||
if (op === "adjust")
|
||
return loyaltyApi.pointAccounts.adjust(tenantId!, active, {
|
||
amount: Number(amount),
|
||
reason: reason || "adjust",
|
||
});
|
||
return loyaltyApi.pointAccounts.expirePoints(tenantId!, active, body);
|
||
},
|
||
onSuccess: async () => {
|
||
toast.success("تراکنش ثبت شد");
|
||
setOp(null);
|
||
await qc.invalidateQueries({ queryKey: ["loyalty", tenantId, "point-"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
if (!tenantId || accountsQ.isLoading) return <LoyaltyPageLoader />;
|
||
if (accountsQ.error)
|
||
return <LoyaltyPageError error={accountsQ.error} onRetry={() => accountsQ.refetch()} />;
|
||
|
||
const ledgerItems =
|
||
ledgerQ.data && "items" in ledgerQ.data ? ledgerQ.data.items : (ledgerQ.data as never) ?? [];
|
||
|
||
return (
|
||
<div>
|
||
<LoyaltyBreadcrumbs items={[{ label: "تراکنش امتیاز" }]} />
|
||
<PageHeader title="تراکنش امتیاز" description="کسب، مصرف، تعدیل و انقضای امتیاز روی حساب واقعی." />
|
||
<div className="mb-4 flex flex-wrap gap-3">
|
||
<Select
|
||
className="min-w-[260px]"
|
||
value={accountId}
|
||
onChange={(e) => setAccountId(e.target.value)}
|
||
>
|
||
<option value="">انتخاب حساب</option>
|
||
{(accountsQ.data ?? []).map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.account_number} — {a.id.slice(0, 8)}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Button onClick={() => setActive(accountId)}>بارگذاری</Button>
|
||
{active ? (
|
||
<>
|
||
<Button variant="outline" onClick={() => setOp("earn")}>
|
||
کسب
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setOp("redeem")}>
|
||
مصرف
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setOp("adjust")}>
|
||
تعدیل
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setOp("expire")}>
|
||
انقضا
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
{balanceQ.data ? (
|
||
<p className="mb-4 text-sm">
|
||
موجودی: <strong>{balanceQ.data.balance}</strong> {balanceQ.data.currency_name}
|
||
</p>
|
||
) : null}
|
||
{!active ? <EmptyState title="حساب امتیاز را انتخاب کنید" /> : null}
|
||
{active && ledgerQ.isLoading ? <LoyaltyPageLoader /> : null}
|
||
{active && ledgerQ.data ? (
|
||
<LoyaltyTablePage
|
||
title="دفترکل امتیاز"
|
||
columns={[
|
||
{ key: "entry_type", header: "نوع", render: (r) => <LoyaltyStatusChip status={String(r.entry_type)} domain="ledger" /> },
|
||
{ key: "amount", header: "مبلغ" },
|
||
{ key: "balance_after", header: "مانده" },
|
||
{ key: "reason", header: "دلیل" },
|
||
{ key: "created_at", header: "زمان" },
|
||
]}
|
||
rows={ledgerItems as unknown as Record<string, unknown>[]}
|
||
/>
|
||
) : null}
|
||
|
||
<Dialog open={!!op} onClose={() => setOp(null)} title="ثبت تراکنش">
|
||
<div className="space-y-3">
|
||
<FormField label="مبلغ">
|
||
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="دلیل">
|
||
<Input value={reason} onChange={(e) => setReason(e.target.value)} />
|
||
</FormField>
|
||
<Button loading={mut.isPending} onClick={() => mut.mutate()}>
|
||
ثبت
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|