feat: kapsamli admin dashboard ekle
This commit is contained in:
+114
-28
@@ -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
|
||||
@@ -83,29 +100,35 @@ enum IngestStatus {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
googleId String? @unique
|
||||
name String?
|
||||
avatarUrl String?
|
||||
role UserRole @default(USER)
|
||||
locale String @default("tr")
|
||||
currency String @default("EUR")
|
||||
theme String @default("system")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
googleId String? @unique
|
||||
name String?
|
||||
avatarUrl String?
|
||||
role UserRole @default(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?
|
||||
|
||||
sessions AuthSession[]
|
||||
subscription Subscription?
|
||||
usageCounters UsageCounter[]
|
||||
savedFolders SavedFolder[]
|
||||
savedAds SavedAd[]
|
||||
filterPresets FilterPreset[]
|
||||
trackedStores TrackedStore[]
|
||||
exportJobs ExportJob[]
|
||||
apiKeys ApiKey[]
|
||||
sessions AuthSession[]
|
||||
subscription Subscription?
|
||||
usageCounters UsageCounter[]
|
||||
savedFolders SavedFolder[]
|
||||
savedAds SavedAd[]
|
||||
filterPresets FilterPreset[]
|
||||
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])
|
||||
}
|
||||
@@ -241,7 +264,7 @@ model Ad {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
brandPage BrandPage? @relation(fields: [brandPageId], references: [id], onDelete: SetNull)
|
||||
brandPage BrandPage? @relation(fields: [brandPageId], references: [id], onDelete: SetNull)
|
||||
creatives AdCreative[]
|
||||
metricsDaily AdMetricDaily[]
|
||||
countryStats AdCountryStat[]
|
||||
@@ -395,7 +418,7 @@ model StoreSnapshot {
|
||||
}
|
||||
|
||||
model StoreProduct {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
externalProductId String?
|
||||
title String
|
||||
@@ -406,11 +429,11 @@ model StoreProduct {
|
||||
compareAtPrice Float?
|
||||
currency String?
|
||||
variantCount Int?
|
||||
isBestSeller Boolean @default(false)
|
||||
isBestSeller Boolean @default(false)
|
||||
firstSeenAt DateTime?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -420,7 +443,7 @@ model StoreProduct {
|
||||
}
|
||||
|
||||
model StorePixel {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
type String
|
||||
value String?
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user