Add balance transactions to user admin panel
Introduces BalanceTransaction model to track per-user balance history (prepayments, refunds, partner credits). Admin can add/delete transactions; current balance is computed as the running sum. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,25 @@ export async function updateUserContact(
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function addBalanceTransaction(
|
||||
userId: string,
|
||||
data: { amount: string; description: string }
|
||||
) {
|
||||
await requireAdmin();
|
||||
const amount = parseFloat(data.amount.replace(",", "."));
|
||||
if (isNaN(amount) || amount === 0) throw new Error("Некорректная сумма");
|
||||
await prisma.balanceTransaction.create({
|
||||
data: { userId, amount, description: data.description.trim() },
|
||||
});
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function deleteBalanceTransaction(userId: string, txId: string) {
|
||||
await requireAdmin();
|
||||
await prisma.balanceTransaction.delete({ where: { id: txId } });
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function revokeUserAccess(userId: string, courseId: string) {
|
||||
const session = await requireAdmin();
|
||||
await prisma.courseEnrollment.delete({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
|
||||
import { UserContactEditor } from "@/components/admin/user-contact-editor";
|
||||
import { UserBalanceBlock } from "@/components/admin/user-balance-block";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ userId: string }>;
|
||||
@@ -27,6 +28,9 @@ export default async function UserPage({ params }: Props) {
|
||||
grantedBy: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
balanceTransactions: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.course.findMany({
|
||||
@@ -73,6 +77,22 @@ export default async function UserPage({ params }: Props) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Balance */}
|
||||
<section className="card-aubade p-6 mb-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||
Баланс
|
||||
</p>
|
||||
<UserBalanceBlock
|
||||
userId={userId}
|
||||
transactions={user.balanceTransactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
amount: Number(tx.amount),
|
||||
description: tx.description,
|
||||
createdAt: tx.createdAt,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Enrollments + bulk grant */}
|
||||
<section className="card-aubade p-6 mb-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { addBalanceTransaction, deleteBalanceTransaction } from "@/app/admin/users/[userId]/actions";
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
padding: "0.4rem 0.6rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
} as React.CSSProperties;
|
||||
|
||||
const focusHandlers = {
|
||||
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
||||
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e.currentTarget.style.borderColor = "var(--border)"),
|
||||
};
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
const sign = amount > 0 ? "+" : "";
|
||||
return `${sign}${amount.toLocaleString("ru-RU", { minimumFractionDigits: 0, maximumFractionDigits: 2 })} ₽`;
|
||||
}
|
||||
|
||||
export function UserBalanceBlock({ userId, transactions }: Props) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [amountVal, setAmountVal] = useState("");
|
||||
const [descVal, setDescVal] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
function handleAdd() {
|
||||
setError("");
|
||||
const num = parseFloat(amountVal.replace(",", "."));
|
||||
if (isNaN(num) || num === 0) { setError("Введите ненулевую сумму"); return; }
|
||||
if (!descVal.trim()) { setError("Добавьте описание"); return; }
|
||||
startTransition(async () => {
|
||||
await addBalanceTransaction(userId, { amount: amountVal, description: descVal });
|
||||
setAmountVal("");
|
||||
setDescVal("");
|
||||
setShowForm(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(txId: string) {
|
||||
setDeletingId(txId);
|
||||
startTransition(async () => {
|
||||
await deleteBalanceTransaction(userId, txId);
|
||||
setDeletingId(null);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Balance summary */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: balance > 0 ? "#3A6A3A" : balance < 0 ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}
|
||||
>
|
||||
{formatAmount(balance)}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
на балансе
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowForm((v) => !v); setError(""); }}
|
||||
className="btn-aubade btn-aubade-accent px-3 py-1.5 text-xs"
|
||||
>
|
||||
{showForm ? "Отмена" : "+ Операция"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showForm && (
|
||||
<div className="p-4 space-y-3" style={{ border: "2px solid var(--border)" }}>
|
||||
<div className="flex gap-3">
|
||||
<div className="space-y-1" style={{ width: 140 }}>
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Сумма, ₽
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amountVal}
|
||||
onChange={(e) => setAmountVal(e.target.value)}
|
||||
placeholder="-3490 или 1000"
|
||||
style={{ ...inputStyle, width: "100%" }}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 flex-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Описание
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={descVal}
|
||||
onChange={(e) => setDescVal(e.target.value)}
|
||||
placeholder="Предоплата курса, возврат, партнёрка..."
|
||||
style={{ ...inputStyle, width: "100%" }}
|
||||
{...focusHandlers}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={pending}
|
||||
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
|
||||
style={{ opacity: pending ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Сохранение..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction list */}
|
||||
{transactions.length > 0 ? (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center gap-3 px-3 py-2 text-xs group"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{
|
||||
minWidth: 80,
|
||||
color: tx.amount > 0 ? "#3A6A3A" : "oklch(0.577 0.245 27.325)",
|
||||
}}
|
||||
>
|
||||
{formatAmount(tx.amount)}
|
||||
</span>
|
||||
<span className="flex-1">{tx.description}</span>
|
||||
<span style={{ color: "var(--muted-foreground)", whiteSpace: "nowrap" }}>
|
||||
{new Date(tx.createdAt).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
disabled={deletingId === tx.id || pending}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||
style={{ color: "oklch(0.577 0.245 27.325)", flexShrink: 0 }}
|
||||
title="Удалить"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Операций ещё нет</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user