feat: kapsamli admin dashboard ekle

This commit is contained in:
2026-08-02 18:10:56 +03:00
parent bf2fc272f2
commit 3af61c82a2
19 changed files with 653 additions and 34 deletions
+86
View File
@@ -33,6 +33,23 @@ enum UsagePeriod {
LIFETIME
}
enum CreditTransactionType {
ADMIN_CREDIT
ADMIN_DEBIT
PAYMENT
REFUND
USAGE
ADJUSTMENT
}
enum PaymentStatus {
PENDING
PAID
FAILED
REFUNDED
CANCELED
}
enum AdSource {
META
ADSPY
@@ -93,6 +110,7 @@ model User {
locale String @default("tr")
currency String @default("EUR")
theme String @default("system")
creditBalance Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@ -106,6 +124,11 @@ model User {
trackedStores TrackedStore[]
exportJobs ExportJob[]
apiKeys ApiKey[]
creditTransactions CreditTransaction[] @relation("CreditOwner")
creditActions CreditTransaction[] @relation("CreditActor")
payments Payment[] @relation("PaymentOwner")
manualPayments Payment[] @relation("PaymentActor")
adminAuditLogs AdminAuditLog[] @relation("AuditActor")
@@index([email])
}
@@ -562,3 +585,66 @@ model ApiKey {
@@index([userId])
@@index([prefix])
}
model CreditTransaction {
id String @id @default(cuid())
userId String
actorId String?
type CreditTransactionType
amount Int
balanceAfter Int
reason String
metadata Json?
createdAt DateTime @default(now())
user User @relation("CreditOwner", fields: [userId], references: [id], onDelete: Cascade)
actor User? @relation("CreditActor", fields: [actorId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([actorId, createdAt])
@@index([type, createdAt])
}
model Payment {
id String @id @default(cuid())
userId String
actorId String?
amountCents Int
currency String @default("EUR")
status PaymentStatus @default(PAID)
provider String @default("MANUAL")
externalId String? @unique
description String?
creditGranted Int @default(0)
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation("PaymentOwner", fields: [userId], references: [id], onDelete: Cascade)
actor User? @relation("PaymentActor", fields: [actorId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([status, createdAt])
@@index([provider, createdAt])
}
model AdminAuditLog {
id String @id @default(cuid())
actorId String?
actorEmail String
action String
targetType String
targetId String?
summary String
details Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
actor User? @relation("AuditActor", fields: [actorId], references: [id], onDelete: SetNull)
@@index([actorId, createdAt])
@@index([action, createdAt])
@@index([targetType, targetId])
@@index([createdAt])
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/current-user";
import { getApiAdmin } from "@/lib/admin-api";
export async function POST() {
await requireAdmin();
const admin = await getApiAdmin();
if (!admin) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
}
@@ -0,0 +1,29 @@
import { PaymentStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
import { prisma } from "@/lib/db";
const schema = z.object({ status: z.nativeEnum(PaymentStatus) });
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const actor = await getApiAdmin();
if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const { id } = await params;
const payment = await prisma.payment.findUnique({ where: { id }, include: { user: { select: { email: true } } } });
if (!payment) return NextResponse.json({ error: "PAYMENT_NOT_FOUND" }, { status: 404 });
const auditContext = requestAuditContext(request);
await prisma.$transaction([
prisma.payment.update({ where: { id }, data: { status: parsed.data.status, paidAt: parsed.data.status === PaymentStatus.PAID ? payment.paidAt || new Date() : payment.paidAt } }),
prisma.adminAuditLog.create({
data: {
actorId: actor.id, actorEmail: actor.email, action: "PAYMENT_STATUS_CHANGED", targetType: "Payment", targetId: id,
summary: `${payment.user.email} ödeme durumu ${payment.status}${parsed.data.status} olarak değiştirildi.`,
details: { before: payment.status, after: parsed.data.status }, ...auditContext
}
})
]);
return NextResponse.json({ ok: true, status: parsed.data.status });
}
+47
View File
@@ -0,0 +1,47 @@
import { CreditTransactionType, PaymentStatus, Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
import { prisma } from "@/lib/db";
const schema = z.object({
userId: z.string().min(1), amountCents: z.coerce.number().int().positive().max(100_000_000),
currency: z.string().trim().length(3).transform((value) => value.toUpperCase()),
description: z.string().trim().max(240).optional().default("Manuel ödeme"),
externalId: z.string().trim().max(120).optional().nullable(), creditGranted: z.coerce.number().int().min(0).max(1_000_000).default(0)
});
export async function POST(request: Request) {
const actor = await getApiAdmin();
if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const auditContext = requestAuditContext(request);
try {
const payment = await prisma.$transaction(async (tx) => {
const target = await tx.user.findUnique({ where: { id: parsed.data.userId }, select: { email: true, creditBalance: true } });
if (!target) throw new Error("USER_NOT_FOUND");
const created = await tx.payment.create({
data: { userId: parsed.data.userId, actorId: actor.id, amountCents: parsed.data.amountCents, currency: parsed.data.currency, status: PaymentStatus.PAID, provider: "MANUAL", externalId: parsed.data.externalId || null, description: parsed.data.description, creditGranted: parsed.data.creditGranted, paidAt: new Date() }
});
let balanceAfter = target.creditBalance;
if (parsed.data.creditGranted > 0) {
const updated = await tx.user.update({ where: { id: parsed.data.userId }, data: { creditBalance: { increment: parsed.data.creditGranted } }, select: { creditBalance: true } });
balanceAfter = updated.creditBalance;
await tx.creditTransaction.create({ data: { userId: parsed.data.userId, actorId: actor.id, type: CreditTransactionType.PAYMENT, amount: parsed.data.creditGranted, balanceAfter, reason: `Manuel ödeme ${created.id}`, metadata: { paymentId: created.id } } });
}
await tx.adminAuditLog.create({
data: {
actorId: actor.id, actorEmail: actor.email, action: "PAYMENT_RECORDED", targetType: "Payment", targetId: created.id,
summary: `${target.email} için ${(parsed.data.amountCents / 100).toFixed(2)} ${parsed.data.currency} manuel ödeme kaydedildi.`,
details: { amountCents: parsed.data.amountCents, currency: parsed.data.currency, creditGranted: parsed.data.creditGranted, balanceAfter }, ...auditContext
}
});
return created;
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
return NextResponse.json({ ok: true, payment });
} catch (error) {
const message = error instanceof Error ? error.message : "UNKNOWN";
return NextResponse.json({ error: message }, { status: message === "USER_NOT_FOUND" ? 404 : 500 });
}
}
@@ -0,0 +1,60 @@
import { CreditTransactionType, Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
import { prisma } from "@/lib/db";
const schema = z.object({
operation: z.enum(["add", "remove"]),
amount: z.coerce.number().int().positive().max(1_000_000),
reason: z.string().trim().min(3).max(240)
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const actor = await getApiAdmin();
if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const { id } = await params;
const { operation, amount, reason } = parsed.data;
const signedAmount = operation === "add" ? amount : -amount;
const auditContext = requestAuditContext(request);
try {
const result = await prisma.$transaction(async (tx) => {
const target = await tx.user.findUnique({ where: { id }, select: { id: true, email: true, creditBalance: true } });
if (!target) throw new Error("USER_NOT_FOUND");
if (target.creditBalance + signedAmount < 0) throw new Error("INSUFFICIENT_CREDIT");
const updated = await tx.user.update({ where: { id }, data: { creditBalance: { increment: signedAmount } }, select: { creditBalance: true } });
const transaction = await tx.creditTransaction.create({
data: {
userId: id,
actorId: actor.id,
type: operation === "add" ? CreditTransactionType.ADMIN_CREDIT : CreditTransactionType.ADMIN_DEBIT,
amount: signedAmount,
balanceAfter: updated.creditBalance,
reason
}
});
await tx.adminAuditLog.create({
data: {
actorId: actor.id,
actorEmail: actor.email,
action: operation === "add" ? "CREDIT_ADDED" : "CREDIT_REMOVED",
targetType: "User",
targetId: id,
summary: `${target.email} için ${Math.abs(signedAmount)} kredi ${operation === "add" ? "eklendi" : "çıkarıldı"}.`,
details: { amount: signedAmount, balanceAfter: updated.creditBalance, reason },
...auditContext
}
});
return { transaction, balance: updated.creditBalance };
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
return NextResponse.json({ ok: true, ...result });
} catch (error) {
const message = error instanceof Error ? error.message : "UNKNOWN";
const status = message === "USER_NOT_FOUND" ? 404 : message === "INSUFFICIENT_CREDIT" ? 409 : 500;
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,33 @@
import { PlanCode, SubscriptionStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
import { prisma } from "@/lib/db";
const schema = z.object({ planCode: z.nativeEnum(PlanCode), status: z.nativeEnum(SubscriptionStatus).default(SubscriptionStatus.ACTIVE) });
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const actor = await getApiAdmin();
if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const { id } = await params;
const [target, plan] = await Promise.all([
prisma.user.findUnique({ where: { id }, include: { subscription: { include: { plan: true } } } }),
prisma.plan.findUnique({ where: { code: parsed.data.planCode } })
]);
if (!target) return NextResponse.json({ error: "USER_NOT_FOUND" }, { status: 404 });
if (!plan) return NextResponse.json({ error: "PLAN_NOT_FOUND" }, { status: 404 });
const auditContext = requestAuditContext(request);
await prisma.$transaction([
prisma.subscription.upsert({ where: { userId: id }, update: { planId: plan.id, status: parsed.data.status, cancelAtPeriodEnd: false }, create: { userId: id, planId: plan.id, status: parsed.data.status } }),
prisma.adminAuditLog.create({
data: {
actorId: actor.id, actorEmail: actor.email, action: "PLAN_CHANGED", targetType: "User", targetId: id,
summary: `${target.email} planı ${target.subscription?.plan.code || "YOK"}${plan.code} olarak değiştirildi.`,
details: { before: target.subscription?.plan.code || null, after: plan.code, status: parsed.data.status }, ...auditContext
}
})
]);
return NextResponse.json({ ok: true, plan: plan.code, status: parsed.data.status });
}
@@ -0,0 +1,33 @@
import { UserRole } from "@prisma/client";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
import { isConfiguredAdminEmail } from "@/lib/admin-emails";
import { prisma } from "@/lib/db";
const schema = z.object({ role: z.nativeEnum(UserRole) });
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const actor = await getApiAdmin();
if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const { id } = await params;
const target = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true, role: true } });
if (!target) return NextResponse.json({ error: "USER_NOT_FOUND" }, { status: 404 });
if (parsed.data.role === UserRole.USER && (target.id === actor.id || isConfiguredAdminEmail(target.email))) {
return NextResponse.json({ error: "PROTECTED_ADMIN" }, { status: 409 });
}
const auditContext = requestAuditContext(request);
await prisma.$transaction([
prisma.user.update({ where: { id }, data: { role: parsed.data.role } }),
prisma.adminAuditLog.create({
data: {
actorId: actor.id, actorEmail: actor.email, action: "ROLE_CHANGED", targetType: "User", targetId: id,
summary: `${target.email} yetkisi ${target.role}${parsed.data.role} olarak değiştirildi.`,
details: { before: target.role, after: parsed.data.role }, ...auditContext
}
})
]);
return NextResponse.json({ ok: true, role: parsed.data.role });
}
+11
View File
@@ -67,6 +67,7 @@ export async function GET(req: Request) {
avatarUrl: profile.picture || null,
locale: profile.locale || "tr",
role: isAdmin ? UserRole.ADMIN : UserRole.USER,
lastLoginAt: new Date(),
subscription: { create: { planId: plan.id, status: "ACTIVE" } }
}
});
@@ -77,9 +78,19 @@ export async function GET(req: Request) {
googleId: user.googleId || profile.sub,
name: user.name || profile.name || null,
avatarUrl: user.avatarUrl || profile.picture || null,
role: isAdmin ? UserRole.ADMIN : user.role,
lastLoginAt: new Date()
}
});
if (isAdmin) {
if (!plan) return redirectToLogin("PLAN_NOT_SEEDED");
await prisma.subscription.upsert({
where: { userId: user.id },
update: { planId: plan.id, status: "ACTIVE", cancelAtPeriodEnd: false },
create: { userId: user.id, planId: plan.id, status: "ACTIVE" }
});
}
}
await createSession(user.id);
+2 -1
View File
@@ -10,7 +10,8 @@ export async function GET() {
email: user.email,
name: user.name,
role: user.role,
plan: user.subscription?.plan.code
plan: user.subscription?.plan.code,
creditBalance: user.creditBalance
}
});
}
+1 -1
View File
@@ -3,5 +3,5 @@ import { Card } from "@/components/ui/card";
export default async function AccountPage() {
const user = await requireUser();
return <Card><h1 className="text-3xl font-black">My Account</h1><p className="mt-4 text-slate-600">{user.email} · Plan: {user.subscription?.plan.name}</p></Card>;
return <div><h1 className="mb-6 text-3xl font-black">Hesabım</h1><div className="grid gap-4 md:grid-cols-3"><Card><div className="text-sm text-slate-500">Hesap</div><div className="mt-2 font-black">{user.email}</div><div className="mt-1 text-xs text-slate-400">Yetki: {user.role}</div></Card><Card><div className="text-sm text-slate-500">Plan</div><div className="mt-2 text-2xl font-black">{user.subscription?.plan.name || "—"}</div><div className="mt-1 text-xs text-slate-400">{user.subscription?.status || "Abonelik yok"}</div></Card><Card><div className="text-sm text-slate-500">Kredi bakiyesi</div><div className="mt-2 text-3xl font-black">{user.creditBalance.toLocaleString("tr-TR")}</div></Card></div></div>;
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { Card } from "@/components/ui/card";
import { AdminNav } from "@/components/admin/admin-nav";
export default async function AdminDataPage() {
await requireAdmin();
const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]);
return <div><h1 className="mb-6 text-3xl font-black">Admin Data</h1><div className="grid gap-4 md:grid-cols-3"><Card><b>{ads}</b><br />Ads</Card><Card><b>{stores}</b><br />Stores</Card><Card><b>{users}</b><br />Users</Card></div><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-2 text-sm">{j.source} · {j.type} · {j.status} · {j.recordsImported} records</div>)}</Card></div>;
return <div><h1 className="mb-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b>{ads}</b><br />Ads</Card><Card><b>{stores}</b><br />Stores</Card><Card><b>{users}</b><br />Users</Card></div><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-2 text-sm">{j.source} · {j.type} · {j.status} · {j.recordsImported} records</div>)}</Card></div>;
}
+27
View File
@@ -0,0 +1,27 @@
import { AdminNav } from "@/components/admin/admin-nav";
import { Card } from "@/components/ui/card";
import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
export default async function AdminLogsPage() {
await requireAdmin();
const [audits, credits, ingestJobs, exportJobs, sessions] = await Promise.all([
prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 100 }),
prisma.creditTransaction.findMany({ include: { user: { select: { email: true } }, actor: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 100 }),
prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 50 }),
prisma.exportJob.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 50 }),
prisma.authSession.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 50 })
]);
return (
<div>
<div className="mb-6"><h1 className="text-3xl font-black">Log Merkezi</h1><p className="mt-1 text-slate-500">Admin audit, kredi hareketleri, veri işleri ve oturum kayıtları.</p></div>
<AdminNav />
<div className="space-y-5">
<Card><h2 className="mb-4 text-xl font-black">Admin audit logları</h2><div className="space-y-2">{audits.map((log) => <div key={log.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><div className="flex flex-wrap justify-between gap-2"><b>{log.action}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1">{log.summary}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipAddress || "IP yok"}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">Audit kaydı yok.</p>}</div></Card>
<Card><h2 className="mb-4 text-xl font-black">Kredi hareketleri</h2><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-left text-sm"><thead className="text-xs uppercase text-slate-500"><tr><th>Kullanıcı</th><th>Tür</th><th>Miktar</th><th>Bakiye</th><th>Neden</th><th>Tarih</th></tr></thead><tbody>{credits.map((tx) => <tr key={tx.id} className="border-t border-slate-100"><td className="py-3">{tx.user.email}</td><td>{tx.type}</td><td className={tx.amount >= 0 ? "font-bold text-emerald-600" : "font-bold text-rose-600"}>{tx.amount > 0 ? "+" : ""}{tx.amount}</td><td>{tx.balanceAfter}</td><td>{tx.reason}</td><td>{tx.createdAt.toLocaleString("tr-TR")}</td></tr>)}</tbody></table></div></Card>
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">Ingest işleri</h2>{ingestJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.source} · {job.type}</b><div className="text-slate-500">{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">Export işleri</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.user.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdAt.toLocaleString("tr-TR")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
<Card><h2 className="mb-4 text-xl font-black">Son oturumlar</h2><div className="grid gap-2 md:grid-cols-2">{sessions.map((session) => <div key={session.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><b>{session.user.email}</b><div className="text-xs text-slate-500">Açılış: {session.createdAt.toLocaleString("tr-TR")} · Bitiş: {session.expiresAt.toLocaleString("tr-TR")}</div></div>)}</div></Card>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Link from "next/link";
import { PaymentStatus, SubscriptionStatus } from "@prisma/client";
import { AdminNav } from "@/components/admin/admin-nav";
import { Card } from "@/components/ui/card";
import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
function money(cents: number | null | undefined, currency = "EUR") {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format((cents || 0) / 100);
}
export default async function AdminOverviewPage() {
await requireAdmin();
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [users, admins, activeSubscriptions, paid, totalCredits, recentPayments, recentAudits] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { role: "ADMIN" } }),
prisma.subscription.count({ where: { status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING] } } }),
prisma.payment.aggregate({ where: { status: PaymentStatus.PAID, createdAt: { gte: since } }, _sum: { amountCents: true }, _count: true }),
prisma.user.aggregate({ _sum: { creditBalance: true } }),
prisma.payment.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 6 }),
prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
]);
return (
<div>
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div><div className="text-sm font-black uppercase tracking-[0.2em] text-violet-600">Kontrol Merkezi</div><h1 className="mt-1 text-3xl font-black">WinningHunter Admin</h1><p className="mt-1 text-slate-500">Kullanıcı, gelir, kredi ve operasyon görünümü.</p></div>
<div className="rounded-2xl bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">Sistem aktif</div>
</div>
<AdminNav />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<Card><div className="text-sm text-slate-500">Toplam kullanıcı</div><div className="mt-2 text-3xl font-black">{users}</div><div className="mt-1 text-xs text-slate-400">{admins} admin</div></Card>
<Card><div className="text-sm text-slate-500">Aktif abonelik</div><div className="mt-2 text-3xl font-black">{activeSubscriptions}</div></Card>
<Card><div className="text-sm text-slate-500">30 gün gelir</div><div className="mt-2 text-3xl font-black">{money(paid._sum.amountCents)}</div><div className="mt-1 text-xs text-slate-400">{paid._count} ödeme</div></Card>
<Card><div className="text-sm text-slate-500">Dağıtılan kredi</div><div className="mt-2 text-3xl font-black">{(totalCredits._sum.creditBalance || 0).toLocaleString("tr-TR")}</div></Card>
<Card><div className="text-sm text-slate-500">Hızlı işlem</div><Link href="/dashboard/admin/users" className="mt-3 inline-block font-black text-violet-700">Kullanıcı yönet </Link></Card>
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<div className="mb-4 flex items-center justify-between"><h2 className="text-xl font-black">Son ödemeler</h2><Link href="/dashboard/admin/payments" className="text-sm font-bold text-violet-700">Tümü</Link></div>
<div className="space-y-3">{recentPayments.map((payment) => <div key={payment.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div><div className="font-bold">{payment.user.email}</div><div className="text-xs text-slate-500">{payment.provider} · {payment.status}</div></div><div className="font-black">{money(payment.amountCents, payment.currency)}</div></div>)}{!recentPayments.length && <div className="text-sm text-slate-500">Henüz ödeme kaydı yok.</div>}</div>
</Card>
<Card>
<div className="mb-4 flex items-center justify-between"><h2 className="text-xl font-black">Son admin işlemleri</h2><Link href="/dashboard/admin/logs" className="text-sm font-bold text-violet-700">Log merkezi</Link></div>
<div className="space-y-3">{recentAudits.map((log) => <div key={log.id} className="rounded-2xl border border-slate-100 p-3"><div className="flex justify-between gap-3"><b>{log.action}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1 text-sm text-slate-600">{log.summary}</p><div className="mt-1 text-xs text-slate-400">{log.actorEmail}</div></div>)}{!recentAudits.length && <div className="text-sm text-slate-500">Henüz audit kaydı yok.</div>}</div>
</Card>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { AdminNav } from "@/components/admin/admin-nav";
import { ManualPaymentForm, PaymentStatusForm } from "@/components/admin/admin-actions";
import { Card } from "@/components/ui/card";
import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
function money(cents: number, currency: string) { return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(cents / 100); }
export default async function AdminPaymentsPage() {
await requireAdmin();
const [users, payments] = await Promise.all([
prisma.user.findMany({ select: { id: true, email: true }, orderBy: { email: "asc" }, take: 500 }),
prisma.payment.findMany({ include: { user: { select: { email: true } }, actor: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 100 })
]);
return (
<div>
<div className="mb-6"><h1 className="text-3xl font-black">Ödemeler</h1><p className="mt-1 text-slate-500">Manuel tahsilat, referans ve ödeme durumlarını yönetin.</p></div>
<AdminNav />
<Card><h2 className="mb-4 text-xl font-black">Manuel ödeme ekle</h2><ManualPaymentForm users={users} /><p className="mt-3 text-xs text-amber-700">Ödeme ile verilen kredi atomik olarak bakiyeye eklenir. İade durumunda kredi otomatik düşmez; kullanıcı ekranından gerekçeli işlem yapın.</p></Card>
<Card className="mt-5 overflow-hidden p-0">
<div className="overflow-x-auto"><table className="w-full min-w-[960px] text-left text-sm"><thead className="bg-slate-50 text-xs uppercase text-slate-500"><tr><th className="p-4">Kullanıcı</th><th>Tutar</th><th>Sağlayıcı</th><th>Kredi</th><th>Referans</th><th>Tarih</th><th>Durum</th></tr></thead><tbody>{payments.map((payment) => <tr key={payment.id} className="border-t border-slate-100"><td className="p-4"><b>{payment.user.email}</b><div className="text-xs text-slate-400">Admin: {payment.actor?.email || "Sistem"}</div></td><td className="font-black">{money(payment.amountCents, payment.currency)}</td><td>{payment.provider}</td><td>{payment.creditGranted}</td><td>{payment.externalId || "—"}</td><td>{payment.createdAt.toLocaleString("tr-TR")}</td><td><PaymentStatusForm paymentId={payment.id} status={payment.status} /></td></tr>)}</tbody></table></div>
{!payments.length && <div className="p-5 text-sm text-slate-500">Henüz ödeme kaydı yok.</div>}
</Card>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Prisma, UserRole } from "@prisma/client";
import { AdminNav } from "@/components/admin/admin-nav";
import { CreditAdjustForm, PlanForm, RoleForm } from "@/components/admin/admin-actions";
import { Card } from "@/components/ui/card";
import { isConfiguredAdminEmail } from "@/lib/admin-emails";
import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
export default async function AdminUsersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
await requireAdmin();
const query = await searchParams;
const q = query.q?.trim();
const role = query.role === "ADMIN" || query.role === "USER" ? query.role as UserRole : undefined;
const where: Prisma.UserWhereInput = { role };
if (q) where.OR = [{ email: { contains: q, mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }];
const [users, plans] = await Promise.all([
prisma.user.findMany({ where, include: { subscription: { include: { plan: true } }, creditTransactions: { orderBy: { createdAt: "desc" }, take: 3 } }, orderBy: { createdAt: "desc" }, take: 100 }),
prisma.plan.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } })
]);
return (
<div>
<div className="mb-6"><h1 className="text-3xl font-black">Kullanıcı Yönetimi</h1><p className="mt-1 text-slate-500">Yetki, plan ve kredi bakiyelerini tek yerden yönetin.</p></div>
<AdminNav />
<form className="mb-5 grid gap-3 rounded-2xl bg-white p-4 shadow-sm md:grid-cols-[1fr_180px_120px]">
<input name="q" defaultValue={q} placeholder="E-posta veya ad ara" className="rounded-xl border border-slate-200 px-4 py-3" />
<select name="role" defaultValue={role || ""} className="rounded-xl border border-slate-200 bg-white px-4 py-3"><option value="">Tüm yetkiler</option><option>ADMIN</option><option>USER</option></select>
<button className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
</form>
<div className="space-y-4">
{users.map((user) => (
<Card key={user.id}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div><div className="flex flex-wrap items-center gap-2"><h2 className="text-lg font-black">{user.name || user.email}</h2><span className={`rounded-full px-2 py-1 text-xs font-bold ${user.role === "ADMIN" ? "bg-violet-100 text-violet-700" : "bg-slate-100 text-slate-600"}`}>{user.role}</span></div><div className="text-sm text-slate-500">{user.email} · {user.googleId ? "Google bağlı" : "E-posta hesabı"}</div><div className="mt-1 text-xs text-slate-400">Son giriş: {user.lastLoginAt?.toLocaleString("tr-TR") || "—"} · Kayıt: {user.createdAt.toLocaleDateString("tr-TR")}</div></div>
<div className="text-right"><div className="text-sm text-slate-500">Plan</div><div className="font-black">{user.subscription?.plan.code || "YOK"}</div></div>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<div className="space-y-3 rounded-2xl border border-slate-100 p-3"><div className="text-xs font-black uppercase tracking-wide text-slate-400">Yetki ve plan</div><RoleForm userId={user.id} currentRole={user.role} protectedAdmin={isConfiguredAdminEmail(user.email)} /><PlanForm userId={user.id} currentPlan={user.subscription?.plan.code || "FREE"} plans={plans.map((plan) => plan.code)} /></div>
<CreditAdjustForm userId={user.id} currentBalance={user.creditBalance} />
</div>
{!!user.creditTransactions.length && <div className="mt-3 flex flex-wrap gap-2">{user.creditTransactions.map((tx) => <span key={tx.id} className="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600">{tx.amount > 0 ? "+" : ""}{tx.amount} · {tx.reason}</span>)}</div>}
</Card>
))}
{!users.length && <Card><p className="text-slate-500">Eşleşen kullanıcı bulunamadı.</p></Card>}
</div>
</div>
);
}
+3 -1
View File
@@ -6,6 +6,7 @@ const nav = [
["Stores", "/dashboard/stores"],
["Store Tracker", "/dashboard/store-tracker"],
["Saved Ads", "/dashboard/saved-ads"],
["Account", "/dashboard/account"],
["TikTok Shop", "#"],
["Magic AI", "#"],
["Trends", "#"],
@@ -25,9 +26,10 @@ export default async function DashboardLayout({ children }: { children: React.Re
{label}
</Link>
))}
{user.role === "ADMIN" && <Link href="/dashboard/admin" className="rounded-xl bg-violet-50 px-3 py-2 text-sm font-black text-violet-700 hover:bg-violet-100">Admin</Link>}
</nav>
<div className="flex items-center gap-3">
<Link href="/pricing" className="rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white">Upgrade</Link>
{user.role === "ADMIN" ? <Link href="/dashboard/admin" className="rounded-xl bg-slate-950 px-4 py-2 text-sm font-bold text-white lg:hidden">Admin</Link> : <Link href="/pricing" className="rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white">Upgrade</Link>}
<div className="text-right text-sm">
<div className="font-bold">{user.name || user.email}</div>
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
async function postJson(url: string, body: unknown) {
const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "İşlem başarısız");
return data;
}
function Feedback({ message, error }: { message: string; error: boolean }) {
if (!message) return null;
return <p className={`mt-2 text-xs font-semibold ${error ? "text-rose-600" : "text-emerald-600"}`}>{message}</p>;
}
export function CreditAdjustForm({ userId, currentBalance }: { userId: string; currentBalance: number }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const operation = String(form.get("operation"));
const amount = Number(form.get("amount"));
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return;
setBusy(true); setMessage("");
try {
const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") });
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); event.currentTarget.reset(); router.refresh();
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="rounded-2xl bg-slate-50 p-3">
<div className="mb-2 text-xs font-bold text-slate-500">Kredi bakiyesi: {currentBalance.toLocaleString("tr-TR")}</div>
<div className="grid gap-2 sm:grid-cols-[110px_100px_1fr_auto]">
<select name="operation" className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option value="add">Kredi ekle</option><option value="remove">Kredi çıkar</option></select>
<input name="amount" type="number" min="1" max="1000000" required placeholder="Miktar" className="rounded-xl border border-slate-200 px-3 py-2 text-sm" />
<input name="reason" minLength={3} maxLength={240} required placeholder="İşlem nedeni" className="rounded-xl border border-slate-200 px-3 py-2 text-sm" />
<button disabled={busy} className="rounded-xl bg-slate-950 px-4 py-2 text-sm font-bold text-white disabled:opacity-50">{busy ? "..." : "Uygula"}</button>
</div>
<Feedback message={message} error={isError} />
</form>
);
}
export function RoleForm({ userId, currentRole, protectedAdmin }: { userId: string; currentRole: "USER" | "ADMIN"; protectedAdmin: boolean }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); const role = String(form.get("role"));
if (role === "USER" && !window.confirm("Bu kullanıcının admin yetkisi kaldırılsın mı?")) return;
setBusy(true); setMessage("");
try { await postJson(`/api/admin/users/${userId}/role`, { role }); setIsError(false); setMessage("Yetki güncellendi"); router.refresh(); }
catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="flex flex-wrap items-center gap-2">
<select name="role" defaultValue={currentRole} disabled={protectedAdmin} className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select>
<button disabled={busy || protectedAdmin} className="rounded-xl bg-violet-700 px-3 py-2 text-sm font-bold text-white disabled:opacity-40">Yetki ata</button>
{protectedAdmin && <span className="text-xs font-semibold text-amber-700">Korumalı admin</span>}
<Feedback message={message} error={isError} />
</form>
);
}
export function PlanForm({ userId, currentPlan, plans }: { userId: string; currentPlan: string; plans: string[] }) {
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage("");
try { await postJson(`/api/admin/users/${userId}/plan`, { planCode: form.get("planCode"), status: form.get("status") }); setIsError(false); setMessage("Plan güncellendi"); router.refresh(); }
catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="flex flex-wrap items-center gap-2">
<select name="planCode" defaultValue={currentPlan} className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm">{plans.map((plan) => <option key={plan}>{plan}</option>)}</select>
<select name="status" defaultValue="ACTIVE" className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option>ACTIVE</option><option>TRIALING</option><option>PAST_DUE</option><option>CANCELED</option><option>EXPIRED</option></select>
<button disabled={busy} className="rounded-xl bg-violet-700 px-3 py-2 text-sm font-bold text-white disabled:opacity-40">Planı kaydet</button>
<Feedback message={message} error={isError} />
</form>
);
}
export function ManualPaymentForm({ users }: { users: Array<{ id: string; email: string }> }) {
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage("");
try {
await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null });
setIsError(false); setMessage("Ödeme kaydedildi"); event.currentTarget.reset(); router.refresh();
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
finally { setBusy(false); }
}
return (
<form onSubmit={submit} className="grid gap-3 md:grid-cols-2">
<select name="userId" required className="rounded-xl border border-slate-200 bg-white px-3 py-3"><option value="">Kullanıcı seçin</option>{users.map((user) => <option key={user.id} value={user.id}>{user.email}</option>)}</select>
<div className="grid grid-cols-[1fr_90px] gap-2"><input name="amount" type="number" min="0.01" step="0.01" required placeholder="Ödeme tutarı" className="rounded-xl border border-slate-200 px-3 py-3" /><select name="currency" defaultValue="EUR" className="rounded-xl border border-slate-200 bg-white px-2"><option>EUR</option><option>USD</option><option>TRY</option></select></div>
<input name="creditGranted" type="number" min="0" max="1000000" defaultValue="0" placeholder="Verilecek kredi" className="rounded-xl border border-slate-200 px-3 py-3" />
<input name="externalId" maxLength={120} placeholder="Dekont / referans no (opsiyonel)" className="rounded-xl border border-slate-200 px-3 py-3" />
<input name="description" maxLength={240} defaultValue="Manuel ödeme" placeholder="Açıklama" className="rounded-xl border border-slate-200 px-3 py-3 md:col-span-2" />
<button disabled={busy} className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50 md:col-span-2">{busy ? "Kaydediliyor..." : "Ödemeyi kaydet"}</button>
<div className="md:col-span-2"><Feedback message={message} error={isError} /></div>
</form>
);
}
export function PaymentStatusForm({ paymentId, status }: { paymentId: string; status: string }) {
const router = useRouter(); const [busy, setBusy] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); const nextStatus = String(form.get("status"));
if ((nextStatus === "REFUNDED" || nextStatus === "CANCELED") && !window.confirm(`Ödeme ${nextStatus} yapılsın mı? Kredi bakiyesi otomatik değişmez.`)) return;
setBusy(true); try { await postJson(`/api/admin/payments/${paymentId}/status`, { status: nextStatus }); router.refresh(); } finally { setBusy(false); }
}
return <form onSubmit={submit} className="flex gap-2"><select name="status" defaultValue={status} className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs"><option>PAID</option><option>PENDING</option><option>FAILED</option><option>REFUNDED</option><option>CANCELED</option></select><button disabled={busy} className="rounded-lg bg-slate-900 px-2 py-1 text-xs font-bold text-white">Kaydet</button></form>;
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
const links = [
["Genel Bakış", "/dashboard/admin"],
["Kullanıcılar", "/dashboard/admin/users"],
["Ödemeler", "/dashboard/admin/payments"],
["Log Merkezi", "/dashboard/admin/logs"],
["Veri İşleri", "/dashboard/admin/data"]
];
export function AdminNav() {
return (
<div className="mb-6 flex flex-wrap gap-2 rounded-2xl border border-slate-200 bg-white p-2 shadow-sm">
{links.map(([label, href]) => (
<Link key={href} href={href} className="rounded-xl px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-violet-50 hover:text-violet-700">
{label}
</Link>
))}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { getSessionUser } from "@/lib/auth/session";
export async function getApiAdmin() {
const user = await getSessionUser();
if (!user || user.role !== "ADMIN") return null;
return user;
}
export function requestAuditContext(request: Request) {
const forwardedFor = request.headers.get("x-forwarded-for");
return {
ipAddress: forwardedFor?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || null,
userAgent: request.headers.get("user-agent")?.slice(0, 500) || null
};
}