feat(identity+admin): CRM analytics + customer notes + user power-actions
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 54s
Build backend images / build gateway (push) Failing after 1m1s
Build backend images / build identity-svc (push) Failing after 55s
Build backend images / build notification-svc (push) Failing after 54s
Build backend images / build render-svc (push) Failing after 52s
Build backend images / build studio-svc (push) Failing after 1m2s
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 54s
Build backend images / build gateway (push) Failing after 1m1s
Build backend images / build identity-svc (push) Failing after 55s
Build backend images / build notification-svc (push) Failing after 54s
Build backend images / build render-svc (push) Failing after 52s
Build backend images / build studio-svc (push) Failing after 1m2s
Modeled on the legacy DivineGateWeb admin (CRM + Security/* actions):
- identity-svc AdminService + AdminController (admin-gated):
- GET /v1/admin/crm/analytics — signups/buyers/conversion/revenue + daily series
(from identity.users + identity.payments)
- GET/PUT /v1/users/{id}/crm — tags / note / pipeline status (user_crm table, mig 20)
- power-actions: POST /v1/users/{id}/{balance,password,charge,moderator,grant-plan}
- admin UI: /admin/crm dashboard (funnel cards + daily signup/revenue bars);
per-user "مدیریت" modal in Users (balance, render charge, plan days, password,
moderator, CRM notes)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- IDENTITY SCHEMA — Part 20: CRM notes/tags per customer
|
||||||
|
-- Lightweight CRM overlay on identity.users (tags, free-form note, pipeline status).
|
||||||
|
-- Acquisition/conversion analytics are computed live from users + payments.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET search_path TO identity, public;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_crm (
|
||||||
|
user_id UUID PRIMARY KEY,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
note TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'new', -- new | contacted | customer | churned
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
+2
-1
@@ -325,7 +325,8 @@
|
|||||||
"discounts": "Discounts",
|
"discounts": "Discounts",
|
||||||
"siteSettings": "Settings",
|
"siteSettings": "Settings",
|
||||||
"messaging": "Messaging",
|
"messaging": "Messaging",
|
||||||
"marketing": "Marketing"
|
"marketing": "Marketing",
|
||||||
|
"crm": "CRM"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "Render Nodes",
|
"title": "Render Nodes",
|
||||||
|
|||||||
+2
-1
@@ -325,7 +325,8 @@
|
|||||||
"discounts": "تخفیفها",
|
"discounts": "تخفیفها",
|
||||||
"siteSettings": "تنظیمات سایت",
|
"siteSettings": "تنظیمات سایت",
|
||||||
"messaging": "پیامرسانی",
|
"messaging": "پیامرسانی",
|
||||||
"marketing": "بازاریابی"
|
"marketing": "بازاریابی",
|
||||||
|
"crm": "مدیریت مشتریان"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "نودهای رندر",
|
"title": "نودهای رندر",
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using FlatRender.IdentitySvc.Domain.Entities;
|
||||||
|
using FlatRender.IdentitySvc.Domain.Enums;
|
||||||
|
using FlatRender.IdentitySvc.Infrastructure.Data;
|
||||||
|
using FlatRender.IdentitySvc.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FlatRender.IdentitySvc.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>Admin CRM analytics, customer notes/tags, and per-user power-actions.</summary>
|
||||||
|
public class AdminService(IdentityDbContext db)
|
||||||
|
{
|
||||||
|
// ── CRM acquisition / conversion analytics ──────────────────────────────
|
||||||
|
|
||||||
|
public async Task<CrmAnalyticsResponse> GetCrmAnalyticsAsync(Guid tenantId, DateTime start, DateTime end)
|
||||||
|
{
|
||||||
|
start = start.Date;
|
||||||
|
end = end.Date.AddDays(1); // inclusive of the end day
|
||||||
|
if ((end - start).TotalDays > 366) end = start.AddDays(366);
|
||||||
|
|
||||||
|
var signups = await db.Users
|
||||||
|
.Where(u => u.TenantId == tenantId && u.DeletedAt == null && u.RegisterDate >= start && u.RegisterDate < end)
|
||||||
|
.Select(u => new { u.Id, u.RegisterDate })
|
||||||
|
.ToListAsync();
|
||||||
|
var ids = signups.Select(s => s.Id).ToList();
|
||||||
|
|
||||||
|
var buyerIds = await db.Payments
|
||||||
|
.Where(p => p.TenantId == tenantId && p.Status == PaymentStatus.Succeeded && ids.Contains(p.UserId))
|
||||||
|
.Select(p => p.UserId).Distinct().ToListAsync();
|
||||||
|
|
||||||
|
var pays = await db.Payments
|
||||||
|
.Where(p => p.TenantId == tenantId && p.Status == PaymentStatus.Succeeded && p.CreatedAt >= start && p.CreatedAt < end)
|
||||||
|
.Select(p => new { p.UserId, p.AmountMinor, p.CreatedAt })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var payingAllTime = await db.Payments
|
||||||
|
.Where(p => p.TenantId == tenantId && p.Status == PaymentStatus.Succeeded)
|
||||||
|
.Select(p => p.UserId).Distinct().CountAsync();
|
||||||
|
|
||||||
|
var daily = new List<CrmDailyPoint>();
|
||||||
|
for (var d = start; d < end; d = d.AddDays(1))
|
||||||
|
{
|
||||||
|
var de = d.AddDays(1);
|
||||||
|
var dayPays = pays.Where(p => p.CreatedAt >= d && p.CreatedAt < de).ToList();
|
||||||
|
daily.Add(new CrmDailyPoint(
|
||||||
|
d.ToString("yyyy-MM-dd"),
|
||||||
|
signups.Count(u => u.RegisterDate >= d && u.RegisterDate < de),
|
||||||
|
dayPays.Select(p => p.UserId).Distinct().Count(),
|
||||||
|
dayPays.Sum(p => p.AmountMinor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = signups.Count;
|
||||||
|
var buyers = buyerIds.Count;
|
||||||
|
return new CrmAnalyticsResponse(
|
||||||
|
total, buyers, total - buyers,
|
||||||
|
total > 0 ? Math.Round((double)buyers / total * 100, 1) : 0,
|
||||||
|
pays.Sum(p => p.AmountMinor), payingAllTime, daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CRM notes / tags ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<UserCrmResponse> GetUserCrmAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var c = await db.Set<UserCrm>().FindAsync(userId);
|
||||||
|
return c == null
|
||||||
|
? new UserCrmResponse([], null, "new")
|
||||||
|
: new UserCrmResponse(c.Tags, c.Note, c.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserCrmResponse> UpsertUserCrmAsync(Guid userId, UpsertUserCrmRequest req)
|
||||||
|
{
|
||||||
|
var c = await db.Set<UserCrm>().FindAsync(userId);
|
||||||
|
var isNew = c == null;
|
||||||
|
c ??= new UserCrm { UserId = userId };
|
||||||
|
if (req.Tags != null) c.Tags = req.Tags;
|
||||||
|
if (req.Note != null) c.Note = req.Note;
|
||||||
|
if (req.Status != null) c.Status = req.Status;
|
||||||
|
c.UpdatedAt = DateTime.UtcNow;
|
||||||
|
if (isNew) db.Add(c);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new UserCrmResponse(c.Tags, c.Note, c.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user power-actions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<User> RequireUser(Guid id) =>
|
||||||
|
await db.Users.FindAsync(id) ?? throw new KeyNotFoundException("User not found");
|
||||||
|
|
||||||
|
public async Task SetBalanceAsync(Guid userId, long amountMinor, bool add)
|
||||||
|
{
|
||||||
|
var u = await RequireUser(userId);
|
||||||
|
u.BalanceMinor = add ? u.BalanceMinor + amountMinor : amountMinor;
|
||||||
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResetPasswordAsync(Guid userId, string newPassword)
|
||||||
|
{
|
||||||
|
var u = await RequireUser(userId);
|
||||||
|
u.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
|
||||||
|
u.PasswordSetAt = DateTime.UtcNow;
|
||||||
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddChargeAsync(Guid userId, int seconds, int renderCount)
|
||||||
|
{
|
||||||
|
var u = await RequireUser(userId);
|
||||||
|
if (seconds != 0) u.UserDailyFreeChargeSec += seconds;
|
||||||
|
if (renderCount != 0)
|
||||||
|
{
|
||||||
|
u.MaxDailyRenderCount += renderCount;
|
||||||
|
u.DailyRemainRenderCount += renderCount;
|
||||||
|
}
|
||||||
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetModeratorAsync(Guid userId, bool enabled)
|
||||||
|
{
|
||||||
|
var u = await RequireUser(userId);
|
||||||
|
u.IsTenantAdmin = enabled;
|
||||||
|
u.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
|
||||||
|
{
|
||||||
|
var plan = await db.UserPlans
|
||||||
|
.Where(p => p.UserId == userId && (planId == Guid.Empty || p.PlanId == planId))
|
||||||
|
.OrderByDescending(p => p.ExpiresAt).FirstOrDefaultAsync()
|
||||||
|
?? throw new KeyNotFoundException("No plan found for this user to extend");
|
||||||
|
var baseDate = plan.ExpiresAt > DateTime.UtcNow ? plan.ExpiresAt : DateTime.UtcNow;
|
||||||
|
plan.ExpiresAt = baseDate.AddDays(days);
|
||||||
|
plan.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using FlatRender.IdentitySvc.Application.Services;
|
||||||
|
using FlatRender.IdentitySvc.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace FlatRender.IdentitySvc.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public class AdminController(AdminService svc) : ControllerBase
|
||||||
|
{
|
||||||
|
private Guid TenantId =>
|
||||||
|
Guid.TryParse(User.FindFirst("tenant_id")?.Value, out var t)
|
||||||
|
? t : Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
// ── CRM analytics ────────────────────────────────────────────────────────
|
||||||
|
[HttpGet("v1/admin/crm/analytics")]
|
||||||
|
public async Task<IActionResult> Crm([FromQuery] DateTime? start, [FromQuery] DateTime? end)
|
||||||
|
{
|
||||||
|
var s = start ?? DateTime.UtcNow.AddDays(-30);
|
||||||
|
var e = end ?? DateTime.UtcNow;
|
||||||
|
return Ok(await svc.GetCrmAnalyticsAsync(TenantId, s, e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CRM notes / tags ───────────────────────────────────────────────────────
|
||||||
|
[HttpGet("v1/users/{userId:guid}/crm")]
|
||||||
|
public async Task<IActionResult> GetCrm(Guid userId) => Ok(await svc.GetUserCrmAsync(userId));
|
||||||
|
|
||||||
|
[HttpPut("v1/users/{userId:guid}/crm")]
|
||||||
|
public async Task<IActionResult> PutCrm(Guid userId, [FromBody] UpsertUserCrmRequest req)
|
||||||
|
=> Ok(await svc.UpsertUserCrmAsync(userId, req));
|
||||||
|
|
||||||
|
// ── Power-actions ──────────────────────────────────────────────────────────
|
||||||
|
[HttpPost("v1/users/{userId:guid}/balance")]
|
||||||
|
public async Task<IActionResult> Balance(Guid userId, [FromBody] SetBalanceRequest req)
|
||||||
|
{
|
||||||
|
await svc.SetBalanceAsync(userId, req.AmountMinor, req.Add);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("v1/users/{userId:guid}/password")]
|
||||||
|
public async Task<IActionResult> Password(Guid userId, [FromBody] ResetPasswordRequest req)
|
||||||
|
{
|
||||||
|
await svc.ResetPasswordAsync(userId, req.NewPassword);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("v1/users/{userId:guid}/charge")]
|
||||||
|
public async Task<IActionResult> Charge(Guid userId, [FromBody] AddChargeRequest req)
|
||||||
|
{
|
||||||
|
await svc.AddChargeAsync(userId, req.Seconds, req.RenderCount);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("v1/users/{userId:guid}/moderator")]
|
||||||
|
public async Task<IActionResult> Moderator(Guid userId, [FromBody] SetFlagRequest req)
|
||||||
|
{
|
||||||
|
await svc.SetModeratorAsync(userId, req.Enabled);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("v1/users/{userId:guid}/grant-plan")]
|
||||||
|
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await svc.GrantPlanDaysAsync(userId, req.PlanId, req.Days);
|
||||||
|
return Ok(new { ok = true });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = new { message = ex.Message } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace FlatRender.IdentitySvc.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Lightweight CRM overlay for a customer (tags / note / pipeline status).</summary>
|
||||||
|
public class UserCrm
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string[] Tags { get; set; } = [];
|
||||||
|
public string? Note { get; set; }
|
||||||
|
public string Status { get; set; } = "new"; // new | contacted | customer | churned
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
|
|||||||
public DbSet<Discount> Discounts => Set<Discount>();
|
public DbSet<Discount> Discounts => Set<Discount>();
|
||||||
public DbSet<UsedDiscount> UsedDiscounts => Set<UsedDiscount>();
|
public DbSet<UsedDiscount> UsedDiscounts => Set<UsedDiscount>();
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
public DbSet<UserCrm> UserCrms => Set<UserCrm>();
|
||||||
|
|
||||||
// Gamification
|
// Gamification
|
||||||
public DbSet<Quest> Quests => Set<Quest>();
|
public DbSet<Quest> Quests => Set<Quest>();
|
||||||
public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>();
|
public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>();
|
||||||
@@ -47,6 +50,12 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
|
|||||||
ConfigureUsers(mb);
|
ConfigureUsers(mb);
|
||||||
ConfigureBilling(mb);
|
ConfigureBilling(mb);
|
||||||
ConfigureGamification(mb);
|
ConfigureGamification(mb);
|
||||||
|
|
||||||
|
mb.Entity<UserCrm>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("user_crm");
|
||||||
|
e.HasKey(x => x.UserId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureTenants(ModelBuilder mb)
|
private static void ConfigureTenants(ModelBuilder mb)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace FlatRender.IdentitySvc.Models;
|
||||||
|
|
||||||
|
// ── CRM analytics (acquisition / conversion funnel) ──────────────────────────
|
||||||
|
|
||||||
|
public record CrmDailyPoint(string Date, int Signups, int Buyers, long RevenueMinor);
|
||||||
|
|
||||||
|
public record CrmAnalyticsResponse(
|
||||||
|
int TotalSignups,
|
||||||
|
int Buyers,
|
||||||
|
int NonBuyers,
|
||||||
|
double ConversionRate,
|
||||||
|
long RevenueMinor,
|
||||||
|
int PayingUsersAllTime,
|
||||||
|
List<CrmDailyPoint> Daily
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── CRM notes / tags per customer ────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record UserCrmResponse(string[] Tags, string? Note, string Status);
|
||||||
|
|
||||||
|
public record UpsertUserCrmRequest(string[]? Tags, string? Note, string? Status);
|
||||||
|
|
||||||
|
// ── User admin power-actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record SetBalanceRequest(long AmountMinor, bool Add); // Add=false → set absolute
|
||||||
|
public record ResetPasswordRequest(string NewPassword);
|
||||||
|
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
|
||||||
|
public record GrantPlanDaysRequest(Guid PlanId, int Days);
|
||||||
|
public record SetFlagRequest(bool Enabled);
|
||||||
@@ -78,6 +78,7 @@ builder.Services.AddScoped<IPlanService, PlanService>();
|
|||||||
builder.Services.AddScoped<IDiscountService, DiscountService>();
|
builder.Services.AddScoped<IDiscountService, DiscountService>();
|
||||||
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
||||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||||
|
builder.Services.AddScoped<AdminService>();
|
||||||
|
|
||||||
// ── HTTP clients ───────────────────────────────────────────────────────────
|
// ── HTTP clients ───────────────────────────────────────────────────────────
|
||||||
builder.Services.AddHttpClient("zarinpal", client =>
|
builder.Services.AddHttpClient("zarinpal", client =>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CrmAdmin } from "@/components/admin/CrmAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <CrmAdmin />;
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export default async function AdminLayout({
|
|||||||
{ href: "/admin/ai", label: t("aiContent") },
|
{ href: "/admin/ai", label: t("aiContent") },
|
||||||
{ href: "/admin/messaging", label: t("messaging") },
|
{ href: "/admin/messaging", label: t("messaging") },
|
||||||
{ href: "/admin/marketing", label: t("marketing") },
|
{ href: "/admin/marketing", label: t("marketing") },
|
||||||
|
{ href: "/admin/crm", label: t("crm") },
|
||||||
{ href: "/admin/users", label: t("users") },
|
{ href: "/admin/users", label: t("users") },
|
||||||
{ href: "/admin/plans", label: t("plans") },
|
{ href: "/admin/plans", label: t("plans") },
|
||||||
{ href: "/admin/discounts", label: t("discounts") },
|
{ href: "/admin/discounts", label: t("discounts") },
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Daily { date: string; signups: number; buyers: number; revenue_minor: number }
|
||||||
|
interface Analytics {
|
||||||
|
total_signups: number; buyers: number; non_buyers: number;
|
||||||
|
conversion_rate: number; revenue_minor: number; paying_users_all_time: number;
|
||||||
|
daily: Daily[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
|
||||||
|
const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||||
|
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
|
||||||
|
function iso(d: Date) { return d.toISOString().slice(0, 10); }
|
||||||
|
function toman(minor: number) { return (minor / 10).toLocaleString("fa-IR"); } // minor=rial → toman
|
||||||
|
|
||||||
|
export function CrmAdmin() {
|
||||||
|
const today = new Date();
|
||||||
|
const monthAgo = new Date(Date.now() - 30 * 864e5);
|
||||||
|
const [start, setStart] = useState(iso(monthAgo));
|
||||||
|
const [end, setEnd] = useState(iso(today));
|
||||||
|
const [data, setData] = useState<Analytics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(`/api/admin/resource/admin/crm/analytics?start=${start}&end=${end}`, { cache: "no-store" });
|
||||||
|
setData(res.ok ? await res.json() : null);
|
||||||
|
setLoading(false);
|
||||||
|
}, [start, end]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const maxSignup = Math.max(1, ...(data?.daily ?? []).map((d) => d.signups));
|
||||||
|
const maxRev = Math.max(1, ...(data?.daily ?? []).map((d) => d.revenue_minor));
|
||||||
|
|
||||||
|
const stat = (label: string, value: string, sub?: string) => (
|
||||||
|
<div className={card}>
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="mt-2 text-2xl font-bold text-white">{value}</div>
|
||||||
|
{sub && <div className="mt-1 text-xs text-gray-500">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">CRM — تحلیل جذب و تبدیل</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">ثبتنامها، خریداران، نرخ تبدیل و درآمد در بازهٔ زمانی انتخابی.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div><label className="mb-1 block text-xs text-gray-500">از</label><input type="date" className={inp} value={start} onChange={(e) => setStart(e.target.value)} /></div>
|
||||||
|
<div><label className="mb-1 block text-xs text-gray-500">تا</label><input type="date" className={inp} value={end} onChange={(e) => setEnd(e.target.value)} /></div>
|
||||||
|
<button className={btn} onClick={load} disabled={loading}>{loading ? "..." : "بارگذاری"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{stat("کل ثبتنام", data.total_signups.toLocaleString("fa-IR"))}
|
||||||
|
{stat("خریداران", data.buyers.toLocaleString("fa-IR"))}
|
||||||
|
{stat("بدون خرید", data.non_buyers.toLocaleString("fa-IR"))}
|
||||||
|
{stat("نرخ تبدیل", data.conversion_rate.toLocaleString("fa-IR") + "٪")}
|
||||||
|
{stat("درآمد بازه", toman(data.revenue_minor) + " تومان", `کل مشتریان: ${data.paying_users_all_time.toLocaleString("fa-IR")}`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={card}>
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">ثبتنام روزانه</div>
|
||||||
|
<div className="flex h-32 items-end gap-1 overflow-x-auto">
|
||||||
|
{data.daily.map((d) => (
|
||||||
|
<div key={d.date} className="flex min-w-[10px] flex-1 flex-col items-center justify-end" title={`${d.date}: ${d.signups} ثبتنام، ${d.buyers} خرید`}>
|
||||||
|
<div className="w-full rounded-t bg-indigo-500/70" style={{ height: `${(d.signups / maxSignup) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={card}>
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">درآمد روزانه</div>
|
||||||
|
<div className="flex h-32 items-end gap-1 overflow-x-auto">
|
||||||
|
{data.daily.map((d) => (
|
||||||
|
<div key={d.date} className="flex min-w-[10px] flex-1 flex-col items-center justify-end" title={`${d.date}: ${toman(d.revenue_minor)} تومان`}>
|
||||||
|
<div className="w-full rounded-t bg-emerald-500/70" style={{ height: `${(d.revenue_minor / maxRev) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!data && !loading && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">دادهای دریافت نشد.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||||
|
const lbl = "mb-1 block text-xs font-medium text-gray-400";
|
||||||
|
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||||
|
|
||||||
|
/** Per-user admin power-actions + CRM notes, opened as a modal from the Users table. */
|
||||||
|
export function UserActions({ row }: { row: Record<string, unknown>; reload?: () => void }) {
|
||||||
|
const id = String(row.id);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// form state
|
||||||
|
const [balance, setBalance] = useState(""); const [balanceAdd, setBalanceAdd] = useState(true);
|
||||||
|
const [pw, setPw] = useState("");
|
||||||
|
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
|
||||||
|
const [planDays, setPlanDays] = useState("");
|
||||||
|
const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new");
|
||||||
|
|
||||||
|
const call = async (path: string, body: object, ok: string) => {
|
||||||
|
setBusy(true); setMsg(null);
|
||||||
|
const res = await fetch(`/api/admin/resource/${path}`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const d = await res.json().catch(() => null);
|
||||||
|
setMsg(res.ok ? ok : (d?.error?.message ?? d?.error ?? "خطا"));
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCrm = async () => {
|
||||||
|
const r = await fetch(`/api/admin/resource/users/${id}/crm`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||||||
|
if (r) { setTags((r.tags ?? []).join(", ")); setNote(r.note ?? ""); setStatus(r.status ?? "new"); }
|
||||||
|
};
|
||||||
|
const saveCrm = async () => {
|
||||||
|
setBusy(true); setMsg(null);
|
||||||
|
const res = await fetch(`/api/admin/resource/users/${id}/crm`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tags: tags.split(",").map((t) => t.trim()).filter(Boolean), note, status }),
|
||||||
|
});
|
||||||
|
setMsg(res.ok ? "یادداشت ذخیره شد ✓" : "خطا"); setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={() => { setOpen(true); setMsg(null); loadCrm(); }}>مدیریت</button>
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 text-right" dir="rtl" onClick={() => setOpen(false)}>
|
||||||
|
<div className={`${card} max-h-[85vh] w-full max-w-lg overflow-y-auto p-5`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">مدیریت کاربر: {String(row.email ?? row.full_name ?? id)}</h2>
|
||||||
|
{msg && <p className="mt-2 rounded-lg bg-[#12152a] px-3 py-2 text-xs text-gray-300">{msg}</p>}
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>موجودی (ریال)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className={inp} type="number" value={balance} onChange={(e) => setBalance(e.target.value)} />
|
||||||
|
<label className="flex items-center gap-1 whitespace-nowrap text-xs text-gray-400"><input type="checkbox" checked={balanceAdd} onChange={(e) => setBalanceAdd(e.target.checked)} /> افزودن</label>
|
||||||
|
<button className={btn} disabled={busy || !balance} onClick={() => call(`users/${id}/balance`, { amount_minor: Number(balance), add: balanceAdd }, "موجودی بهروزرسانی شد ✓")}>اعمال</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>شارژ رندر</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input className={`${inp} max-w-[120px]`} type="number" placeholder="ثانیه" value={seconds} onChange={(e) => setSeconds(e.target.value)} />
|
||||||
|
<input className={`${inp} max-w-[120px]`} type="number" placeholder="تعداد رندر" value={renders} onChange={(e) => setRenders(e.target.value)} />
|
||||||
|
<button className={btn} disabled={busy || (!seconds && !renders)} onClick={() => call(`users/${id}/charge`, { seconds: Number(seconds) || 0, render_count: Number(renders) || 0 }, "شارژ اضافه شد ✓")}>افزودن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>تمدید پلن (روز)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className={inp} type="number" value={planDays} onChange={(e) => setPlanDays(e.target.value)} />
|
||||||
|
<button className={btn} disabled={busy || !planDays} onClick={() => call(`users/${id}/grant-plan`, { plan_id: "00000000-0000-0000-0000-000000000000", days: Number(planDays) }, "پلن تمدید شد ✓")}>تمدید</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>تغییر رمز عبور</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className={inp} type="text" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="رمز جدید" />
|
||||||
|
<button className={btn} disabled={busy || pw.length < 8} onClick={() => call(`users/${id}/password`, { new_password: pw }, "رمز تغییر کرد ✓")}>تغییر</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} flex items-center justify-between p-3`}>
|
||||||
|
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className={btn} disabled={busy} onClick={() => call(`users/${id}/moderator`, { enabled: true }, "مدیر شد ✓")}>اعطا</button>
|
||||||
|
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" disabled={busy} onClick={() => call(`users/${id}/moderator`, { enabled: false }, "لغو شد ✓")}>لغو</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} p-3`}>
|
||||||
|
<label className={lbl}>یادداشت CRM</label>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<input className={inp} placeholder="برچسبها (با کاما)" value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||||
|
<select className={inp} value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
|
<option value="new">جدید</option><option value="contacted">تماسگرفته</option><option value="customer">مشتری</option><option value="churned">ریزشکرده</option>
|
||||||
|
</select>
|
||||||
|
<textarea className={`${inp} min-h-[60px]`} placeholder="یادداشت" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||||
|
<div><button className={btn} disabled={busy} onClick={saveCrm}>ذخیره یادداشت</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button className="rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e]" onClick={() => setOpen(false)}>بستن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ResourceConfig } from "@/components/admin/AdminResource";
|
import type { ResourceConfig } from "@/components/admin/AdminResource";
|
||||||
|
import { UserActions } from "@/components/admin/UserActions";
|
||||||
|
|
||||||
const badge = (ok: boolean, yes: string, no: string) =>
|
const badge = (ok: boolean, yes: string, no: string) =>
|
||||||
ok ? (
|
ok ? (
|
||||||
@@ -159,7 +160,12 @@ export const usersConfig: ResourceConfig = {
|
|||||||
{ key: "register_mode", label: "Source" },
|
{ key: "register_mode", label: "Source" },
|
||||||
{ key: "ban_account", label: "Status", render: (r) => badge(!r.ban_account, "active", "banned") },
|
{ key: "ban_account", label: "Status", render: (r) => badge(!r.ban_account, "active", "banned") },
|
||||||
],
|
],
|
||||||
rowActions: banAction,
|
rowActions: (row, reload) => (
|
||||||
|
<>
|
||||||
|
<UserActions row={row} reload={reload} />
|
||||||
|
{banAction(row, reload)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plansConfig: ResourceConfig = {
|
export const plansConfig: ResourceConfig = {
|
||||||
|
|||||||
Reference in New Issue
Block a user