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:
2026-05-07 09:24:25 +05:00
parent 48721759d3
commit 93e74951a7
8 changed files with 593 additions and 13 deletions
+185
View File
@@ -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>
);
}