initialize admin panel and necessry pages

This commit is contained in:
2026-09-25 15:01:33 +03:30
parent 598abe604b
commit 9d4ad26dcc
31 changed files with 3565 additions and 7 deletions

View File

@@ -0,0 +1,126 @@
using GreenHome.Api.Filters;
using GreenHome.Application;
using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain;
using Microsoft.AspNetCore.Mvc;
namespace GreenHome.Api.Controllers;
[ApiController]
[Route("api/admin")]
[ServiceFilter(typeof(AdminAuthFilter))]
public sealed class AdminController : ControllerBase
{
private readonly IAdminService _adminService;
public AdminController(IAdminService adminService)
{
_adminService = adminService;
}
// ==================== Dashboard ====================
[HttpGet("dashboard")]
public async Task<ActionResult<AdminDashboardDto>> Dashboard(CancellationToken cancellationToken)
{
var result = await _adminService.GetDashboardAsync(cancellationToken);
return Ok(result);
}
// ==================== Users ====================
[HttpGet("users")]
public async Task<ActionResult<PagedResult<AdminUserListItemDto>>> Users(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var filter = new AdminUserFilter
{
Search = search,
Page = Math.Max(1, page),
PageSize = Math.Clamp(pageSize, 1, 100)
};
var result = await _adminService.GetUsersAsync(filter, cancellationToken);
return Ok(result);
}
[HttpGet("users/{id:int}")]
public async Task<ActionResult<AdminUserDetailDto>> GetUser(int id, CancellationToken cancellationToken)
{
var result = await _adminService.GetUserAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("users/{id:int}/role")]
public async Task<IActionResult> SetUserRole(int id, [FromBody] ChangeUserRoleRequest request, CancellationToken cancellationToken)
{
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
var outcome = await _adminService.SetUserRoleAsync(id, request.Role, currentAdminId, cancellationToken);
return outcome switch
{
UserMutationOutcome.Ok => Ok(new { success = true }),
UserMutationOutcome.NotFound => NotFound(),
UserMutationOutcome.ConflictSelf => Conflict(new { message = "نمی‌توانید نقش خود را تغییر دهید" }),
UserMutationOutcome.ConflictLastAdmin => Conflict(new { message = "آخرین مدیر را نمی‌توان تغییر نقش داد" }),
_ => NotFound()
};
}
[HttpPut("users/{id:int}/status")]
public async Task<IActionResult> SetUserStatus(int id, [FromBody] ChangeUserStatusRequest request, CancellationToken cancellationToken)
{
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
var outcome = await _adminService.SetUserStatusAsync(id, request.IsEnabled, currentAdminId, cancellationToken);
return outcome switch
{
UserMutationOutcome.Ok => Ok(new { success = true }),
UserMutationOutcome.NotFound => NotFound(),
UserMutationOutcome.ConflictSelf => Conflict(new { message = "نمی‌توانید اکانت خود را غیرفعال کنید" }),
UserMutationOutcome.ConflictLastAdmin => Conflict(new { message = "آخرین مدیر فعال را نمی‌توان غیرفعال کرد" }),
_ => NotFound()
};
}
// ==================== Devices ====================
[HttpGet("devices")]
public async Task<ActionResult<PagedResult<AdminDeviceListItemDto>>> Devices(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var filter = new AdminDeviceFilter
{
Search = search,
Page = Math.Max(1, page),
PageSize = Math.Clamp(pageSize, 1, 100)
};
var result = await _adminService.GetDevicesAsync(filter, cancellationToken);
return Ok(result);
}
[HttpGet("devices/{id:int}")]
public async Task<ActionResult<AdminDeviceDetailDto>> GetDevice(int id, CancellationToken cancellationToken)
{
var result = await _adminService.GetDeviceAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
// ==================== Usage ====================
[HttpGet("usage")]
public async Task<ActionResult<IReadOnlyList<UsageDayDto>>> Usage(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? userId,
CancellationToken cancellationToken = default)
{
var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken);
return Ok(result);
}
}

View File

@@ -0,0 +1,60 @@
using GreenHome.Application;
using GreenHome.Domain;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace GreenHome.Api.Filters;
/// <summary>
/// Guards all api/admin/* endpoints:
/// - missing/invalid/expired token -> 401
/// - valid token but Role != Admin -> 403
/// Role is always read from the server-side user lookup (IAuthService.GetUserByTokenAsync
/// does a DB lookup), never from client-provided claims.
/// </summary>
public sealed class AdminAuthFilter : IAsyncActionFilter
{
private readonly IAuthService _authService;
public AdminAuthFilter(IAuthService authService)
{
_authService = authService;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var header = context.HttpContext.Request.Headers.Authorization.ToString();
var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? header["Bearer ".Length..].Trim()
: null;
if (string.IsNullOrWhiteSpace(token))
{
context.Result = new UnauthorizedObjectResult(new { message = "Token ارسال نشده است" });
return;
}
var user = await _authService.GetUserByTokenAsync(token, context.HttpContext.RequestAborted);
if (user == null)
{
// invalid/expired token OR disabled user
context.Result = new UnauthorizedObjectResult(new { message = "Token نامعتبر است" });
return;
}
if (user.Role != UserRole.Admin)
{
context.Result = new ObjectResult(new { message = "دسترسی شما به این بخش مجاز نیست" })
{
StatusCode = StatusCodes.Status403Forbidden
};
return;
}
// expose the authenticated admin to the controller without a second DB round-trip
context.HttpContext.Items["AdminUser"] = user;
await next();
}
}

View File

@@ -53,6 +53,9 @@ void ConfigureCoreServices(WebApplicationBuilder builder)
builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile));
builder.Services.AddValidatorsFromAssemblyContaining<GreenHome.Application.DeviceDtoValidator>();
// Admin panel auth filter (used via [ServiceFilter] on AdminController)
builder.Services.AddScoped<GreenHome.Api.Filters.AdminAuthFilter>();
builder.Services.AddInfrastructure(builder.Configuration);
}

View File

@@ -22,5 +22,9 @@
"TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS"
},
"Admin": {
"OnlineWindowMinutes": 15
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,129 @@
using Domain = GreenHome.Domain;
namespace GreenHome.Application.Dtos.Admin;
// ==================== Dashboard ====================
public sealed class AdminDashboardDto
{
public int TotalUsers { get; set; }
public int TotalDevices { get; set; }
public int OnlineDevices { get; set; }
public int OfflineDevices { get; set; }
public int AlertsLast24h { get; set; }
public int SmsLast7Days { get; set; }
public int AiQueriesLast7Days { get; set; }
/// <summary>تعداد SMS بر اساس روز برای ۷ روز اخیر (sparkline)</summary>
public IReadOnlyList<UsageDayDto> SmsDailyLast7Days { get; set; } = [];
}
// ==================== Users ====================
public sealed class AdminUserFilter
{
public string? Search { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
public sealed class AdminUserListItemDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Family { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty;
public Domain.UserRole Role { get; set; }
public bool IsDisabled { get; set; }
public int DeviceCount { get; set; }
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class AdminUserDetailDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Family { get; set; } = string.Empty;
public string Mobile { get; set; } = string.Empty;
public Domain.UserRole Role { get; set; }
public bool IsDisabled { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastLoginAt { get; set; }
public IReadOnlyList<AdminDeviceListItemDto> Devices { get; set; } = [];
}
public sealed class ChangeUserRoleRequest
{
public Domain.UserRole Role { get; set; }
}
public sealed class ChangeUserStatusRequest
{
public bool IsEnabled { get; set; }
}
/// <summary>
/// نتیجه تغییر نقش/وضعیت کاربر توسط admin.
/// </summary>
public enum UserMutationOutcome
{
Ok,
NotFound,
ConflictSelf,
ConflictLastAdmin
}
// ==================== Devices ====================
public sealed class AdminDeviceFilter
{
public string? Search { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
public sealed class AdminDeviceListItemDto
{
public int Id { get; set; }
public string DeviceName { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public string OwnerName { get; set; } = string.Empty;
public string OwnerMobile { get; set; } = string.Empty;
public bool IsOnline { get; set; }
public DateTime? LastTelemetryAt { get; set; }
public decimal? TemperatureC { get; set; }
public decimal? HumidityPercent { get; set; }
}
public sealed class AdminDeviceDetailDto
{
public int Id { get; set; }
public string DeviceName { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public string OwnerName { get; set; } = string.Empty;
public string OwnerFamily { get; set; } = string.Empty;
public string OwnerMobile { get; set; } = string.Empty;
public bool IsOnline { get; set; }
public int MemberCount { get; set; }
public string? ProductType { get; set; }
public string? Province { get; set; }
public string? City { get; set; }
public string? DevicePhoneNumber { get; set; }
public int? UploadIntervalMin { get; set; }
public int? RelayCount { get; set; }
public IReadOnlyList<TelemetryDto> LastTelemetry { get; set; } = [];
}
// ==================== Usage ====================
public sealed class UsageDayDto
{
public DateTime Date { get; set; }
public int SmsCount { get; set; }
public int VoiceCallCount { get; set; }
public int AiQueryCount { get; set; }
public int AiTokenCount { get; set; }
}

View File

@@ -9,4 +9,5 @@ public sealed class UserDto
public string Name { get; set; } = string.Empty;
public string Family { get; set; } = string.Empty;
public Domain.UserRole Role { get; set; }
public bool IsDisabled { get; set; }
}

View File

@@ -0,0 +1,15 @@
using GreenHome.Application.Dtos.Admin;
namespace GreenHome.Application;
public interface IAdminService
{
Task<AdminDashboardDto> GetDashboardAsync(CancellationToken cancellationToken);
Task<PagedResult<AdminUserListItemDto>> GetUsersAsync(AdminUserFilter filter, CancellationToken cancellationToken);
Task<AdminUserDetailDto?> GetUserAsync(int id, CancellationToken cancellationToken);
Task<UserMutationOutcome> SetUserRoleAsync(int id, Domain.UserRole role, int currentAdminId, CancellationToken cancellationToken);
Task<UserMutationOutcome> SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken);
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,21 @@
namespace GreenHome.Domain;
/// <summary>
/// مصرف هر کاربر در هر روز (تعداد SMS، تماس صوتی، query AI و توکن AI).
/// </summary>
public sealed class UsageCounter
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
/// <summary>
/// تاریخ (فقط date). روز از DateTime.UtcNow مشتق می‌شود تا با Timestampهای Utc هم‌خوان باشد.
/// </summary>
public DateOnly Date { get; set; }
public int SmsCount { get; set; }
public int VoiceCallCount { get; set; }
public int AiQueryCount { get; set; }
public int AiTokenCount { get; set; }
}

View File

@@ -7,6 +7,7 @@ public sealed class User
public string Name { get; set; } = string.Empty;
public string Family { get; set; } = string.Empty;
public UserRole Role { get; set; } = UserRole.Normal;
public bool IsDisabled { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public ICollection<DeviceUser> DeviceUsers { get; set; } = new List<DeviceUser>();

View File

@@ -30,6 +30,7 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet<Domain.DevicePost> DevicePosts => Set<Domain.DevicePost>();
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -445,5 +446,17 @@ public sealed class GreenHomeDbContext : DbContext
b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
b.HasIndex(x => x.EventType);
});
modelBuilder.Entity<Domain.UsageCounter>(b =>
{
b.ToTable("UsageCounters");
b.HasKey(x => x.Id);
b.HasOne(x => x.User)
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.UserId, x.Date }).IsUnique();
b.HasIndex(x => x.Date);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
{
/// <inheritdoc />
public partial class AdminPanel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDisabled",
table: "Users",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "UsageCounters",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
Date = table.Column<DateOnly>(type: "date", nullable: false),
SmsCount = table.Column<int>(type: "int", nullable: false),
VoiceCallCount = table.Column<int>(type: "int", nullable: false),
AiQueryCount = table.Column<int>(type: "int", nullable: false),
AiTokenCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UsageCounters", x => x.Id);
table.ForeignKey(
name: "FK_UsageCounters_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UsageCounters_Date",
table: "UsageCounters",
column: "Date");
migrationBuilder.CreateIndex(
name: "IX_UsageCounters_UserId_Date",
table: "UsageCounters",
columns: new[] { "UserId", "Date" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UsageCounters");
migrationBuilder.DropColumn(
name: "IsDisabled",
table: "Users");
}
}
}

View File

@@ -978,6 +978,42 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.ToTable("Telemetry", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.UsageCounter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AiQueryCount")
.HasColumnType("int");
b.Property<int>("AiTokenCount")
.HasColumnType("int");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<int>("SmsCount")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("VoiceCallCount")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Date");
b.HasIndex("UserId", "Date")
.IsUnique();
b.ToTable("UsageCounters", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.User", b =>
{
b.Property<int>("Id")
@@ -994,6 +1030,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsDisabled")
.HasColumnType("bit");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
@@ -1417,6 +1456,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Navigation("UserDailyReport");
});
modelBuilder.Entity("GreenHome.Domain.UsageCounter", b =>
{
b.HasOne("GreenHome.Domain.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b =>
{
b.HasOne("GreenHome.Domain.Device", "Device")

View File

@@ -1,6 +1,7 @@
using GreenHome.Application;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.AI;
using GreenHome.Infrastructure.Services.Admin;
using GreenHome.Infrastructure.Services.Alert;
using GreenHome.Infrastructure.Services.Auth;
using GreenHome.Infrastructure.Services.Checklist;
@@ -9,6 +10,7 @@ using GreenHome.Infrastructure.Services.PowerOutage;
using GreenHome.Infrastructure.Services.Report;
using GreenHome.Infrastructure.Services.Sun;
using GreenHome.Infrastructure.Services.Telemetry;
using GreenHome.Infrastructure.Services.Usage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -34,6 +36,12 @@ public static class DependencyInjection
// Auth
services.AddScoped<IAuthService, AuthService>();
// Usage tracking
services.AddScoped<UsageTracker>();
// Admin
services.AddScoped<IAdminService, AdminService>();
// Alert
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAlertConditionService, AlertConditionService>();

View File

@@ -1,6 +1,7 @@
using GreenHome.Application;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.Usage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -9,13 +10,16 @@ namespace GreenHome.Infrastructure.Services.AI;
public sealed class AIQueryService : IAIQueryService
{
private readonly GreenHomeDbContext _dbContext;
private readonly UsageTracker _usageTracker;
private readonly ILogger<AIQueryService> _logger;
public AIQueryService(
GreenHomeDbContext dbContext,
UsageTracker usageTracker,
ILogger<AIQueryService> logger)
{
_dbContext = dbContext;
_usageTracker = usageTracker;
_logger = logger;
}
@@ -26,6 +30,11 @@ public sealed class AIQueryService : IAIQueryService
_dbContext.AIQueries.Add(query);
await _dbContext.SaveChangesAsync(cancellationToken);
if (query.UserId.HasValue)
{
await _usageTracker.RecordAsync(query.UserId.Value, UsageKind.AiQuery, tokens: query.TotalTokens, cancellationToken: cancellationToken);
}
_logger.LogInformation("AI query saved: {QueryId}, Tokens: {TotalTokens}",
query.Id, query.TotalTokens);

View File

@@ -0,0 +1,399 @@
using AutoMapper;
using GreenHome.Application;
using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GreenHome.Infrastructure.Services.Admin;
public sealed class AdminService : IAdminService
{
private readonly GreenHomeDbContext _dbContext;
private readonly IMapper _mapper;
private readonly int _onlineWindowMinutes;
public AdminService(GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration)
{
_dbContext = dbContext;
_mapper = mapper;
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
}
// ==================== Dashboard ====================
public async Task<AdminDashboardDto> GetDashboardAsync(CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var onlineCutoff = now.AddMinutes(-_onlineWindowMinutes);
var dayAgo = now.AddDays(-1);
var weekAgo = now.AddDays(-7);
var totalUsers = await _dbContext.Users.CountAsync(cancellationToken);
var totalDevices = await _dbContext.Devices.CountAsync(cancellationToken);
var onlineDevices = await _dbContext.TelemetryRecords
.Where(t => t.ServerTimestampUtc >= onlineCutoff)
.Select(t => t.DeviceId)
.Distinct()
.CountAsync(cancellationToken);
var alertsLast24h = await _dbContext.AlertLogs
.CountAsync(a => a.SentAt >= dayAgo, cancellationToken);
var aiQueriesLast7Days = await _dbContext.AIQueries
.CountAsync(q => q.CreatedAt >= weekAgo, cancellationToken);
// ponytail: SMS count + sparkline come from UsageCounters (single source of truth,
// matches the usage page); AlertLogs is no longer consulted here.
var weekStart = DateOnly.FromDateTime(now.Date.AddDays(-6));
var smsCounters = await _dbContext.UsageCounters
.AsNoTracking()
.Where(u => u.Date >= weekStart)
.GroupBy(u => u.Date)
.Select(g => new { Date = g.Key, Count = g.Sum(x => x.SmsCount) })
.ToListAsync(cancellationToken);
var smsLast7Days = smsCounters.Sum(x => x.Count);
var daily = new List<UsageDayDto>(7);
for (var i = 6; i >= 0; i--)
{
var day = DateOnly.FromDateTime(now.Date.AddDays(-i));
daily.Add(new UsageDayDto
{
Date = day.ToDateTime(new TimeOnly(0, 0)),
SmsCount = smsCounters.FirstOrDefault(x => x.Date == day)?.Count ?? 0
});
}
return new AdminDashboardDto
{
TotalUsers = totalUsers,
TotalDevices = totalDevices,
OnlineDevices = onlineDevices,
OfflineDevices = totalDevices - onlineDevices,
AlertsLast24h = alertsLast24h,
SmsLast7Days = smsLast7Days,
AiQueriesLast7Days = aiQueriesLast7Days,
SmsDailyLast7Days = daily
};
}
// ==================== Users ====================
public async Task<PagedResult<AdminUserListItemDto>> GetUsersAsync(AdminUserFilter filter, CancellationToken cancellationToken)
{
if (filter.Page <= 0) filter.Page = 1;
if (filter.PageSize <= 0) filter.PageSize = 20;
IQueryable<Domain.User> query = _dbContext.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(filter.Search))
{
// normalize Persian/Arabic digits so «۰۹...» finds the stored «09...»
var search = NormalizeDigits(filter.Search.Trim());
query = query.Where(u => u.Name.Contains(search) || u.Family.Contains(search) || u.Mobile.Contains(search));
}
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(u => u.LastLoginAt)
.ThenByDescending(u => u.Id)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync(cancellationToken);
var ids = items.Select(u => u.Id).ToList();
var deviceCounts = await _dbContext.Devices
.Where(d => ids.Contains(d.UserId))
.GroupBy(d => d.UserId)
.Select(g => new { UserId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.UserId, x => x.Count, cancellationToken);
return new PagedResult<AdminUserListItemDto>
{
Items = items.Select(u => new AdminUserListItemDto
{
Id = u.Id,
Name = u.Name,
Family = u.Family,
Mobile = u.Mobile,
Role = u.Role,
IsDisabled = u.IsDisabled,
DeviceCount = deviceCounts.TryGetValue(u.Id, out var c) ? c : 0,
LastLoginAt = u.LastLoginAt,
CreatedAt = u.CreatedAt
}).ToList(),
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
public async Task<AdminUserDetailDto?> GetUserAsync(int id, CancellationToken cancellationToken)
{
var user = await _dbContext.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
if (user == null) return null;
var deviceIds = await _dbContext.Devices
.Where(d => d.UserId == id)
.Select(d => d.Id)
.ToListAsync(cancellationToken);
var (latestMap, _) = await GetDeviceMetaAsync(deviceIds, cancellationToken);
return new AdminUserDetailDto
{
Id = user.Id,
Name = user.Name,
Family = user.Family,
Mobile = user.Mobile,
Role = user.Role,
IsDisabled = user.IsDisabled,
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt,
Devices = await BuildDeviceListAsync(deviceIds, latestMap, cancellationToken)
};
}
public async Task<UserMutationOutcome> SetUserRoleAsync(int id, UserRole role, int currentAdminId, CancellationToken cancellationToken)
{
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
if (user == null) return UserMutationOutcome.NotFound;
if (id == currentAdminId) return UserMutationOutcome.ConflictSelf;
// don't allow demoting/removing the last active admin
if (user.Role == UserRole.Admin && role != UserRole.Admin)
{
var activeAdmins = await _dbContext.Users
.CountAsync(u => u.Role == UserRole.Admin && !u.IsDisabled, cancellationToken);
if (activeAdmins <= 1) return UserMutationOutcome.ConflictLastAdmin;
}
user.Role = role;
await _dbContext.SaveChangesAsync(cancellationToken);
return UserMutationOutcome.Ok;
}
public async Task<UserMutationOutcome> SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken)
{
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
if (user == null) return UserMutationOutcome.NotFound;
if (id == currentAdminId) return UserMutationOutcome.ConflictSelf;
// disabling the last active admin would lock the panel out
if (!isEnabled && user.Role == UserRole.Admin && !user.IsDisabled)
{
var activeAdmins = await _dbContext.Users
.CountAsync(u => u.Role == UserRole.Admin && !u.IsDisabled, cancellationToken);
if (activeAdmins <= 1) return UserMutationOutcome.ConflictLastAdmin;
}
user.IsDisabled = !isEnabled;
await _dbContext.SaveChangesAsync(cancellationToken);
return UserMutationOutcome.Ok;
}
// ==================== Devices ====================
public async Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken)
{
if (filter.Page <= 0) filter.Page = 1;
if (filter.PageSize <= 0) filter.PageSize = 20;
IQueryable<Domain.Device> query = _dbContext.Devices.AsNoTracking().Include(d => d.User);
if (!string.IsNullOrWhiteSpace(filter.Search))
{
var search = NormalizeDigits(filter.Search.Trim());
query = query.Where(d =>
d.DeviceName.Contains(search) ||
d.Location.Contains(search) ||
d.User.Name.Contains(search) ||
d.User.Family.Contains(search) ||
d.User.Mobile.Contains(search));
}
var totalCount = await query.CountAsync(cancellationToken);
var page = await query
.OrderBy(d => d.DeviceName)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync(cancellationToken);
var deviceIds = page.Select(d => d.Id).ToList();
var (latestMap, _) = await GetDeviceMetaAsync(deviceIds, cancellationToken);
var items = page.Select(d => ToDeviceListItem(d, latestMap)).ToList();
return new PagedResult<AdminDeviceListItemDto>
{
Items = items,
TotalCount = totalCount,
Page = filter.Page,
PageSize = filter.PageSize
};
}
public async Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken)
{
var device = await _dbContext.Devices.AsNoTracking()
.Include(d => d.User)
.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
if (device == null) return null;
var settings = await _dbContext.DeviceSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.DeviceId == id, cancellationToken);
var memberCount = await _dbContext.DeviceUsers
.CountAsync(du => du.DeviceId == id, cancellationToken);
var lastTelemetry = await _dbContext.TelemetryRecords
.AsNoTracking()
.Where(t => t.DeviceId == id)
.OrderByDescending(t => t.ServerTimestampUtc)
.Take(10)
.ToListAsync(cancellationToken);
var (latestMap, _) = await GetDeviceMetaAsync([id], cancellationToken);
return new AdminDeviceDetailDto
{
Id = device.Id,
DeviceName = device.DeviceName,
Location = device.Location,
OwnerName = device.User.Name,
OwnerFamily = device.User.Family,
OwnerMobile = device.User.Mobile,
IsOnline = latestMap.TryGetValue(id, out var last) && last.ServerTimestampUtc >= DateTime.UtcNow.AddMinutes(-_onlineWindowMinutes),
MemberCount = memberCount,
ProductType = settings?.ProductType,
Province = settings?.Province,
City = settings?.City,
DevicePhoneNumber = settings?.DevicePhoneNumber,
UploadIntervalMin = settings?.UploadIntervalMin,
RelayCount = settings?.RelayCount,
LastTelemetry = _mapper.Map<IReadOnlyList<TelemetryDto>>(lastTelemetry)
};
}
// ==================== Usage ====================
public async Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken)
{
// default window: last 30 days
var fromDate = DateOnly.FromDateTime((from ?? DateTime.UtcNow.AddDays(-30)).Date);
var toDate = DateOnly.FromDateTime(to?.Date ?? DateTime.UtcNow.Date);
IQueryable<Domain.UsageCounter> query = _dbContext.UsageCounters.AsNoTracking()
.Where(u => u.Date >= fromDate && u.Date <= toDate);
if (userId.HasValue)
query = query.Where(u => u.UserId == userId.Value);
var rows = await query
.GroupBy(u => u.Date)
.Select(g => new
{
Date = g.Key,
Sms = g.Sum(x => x.SmsCount),
Voice = g.Sum(x => x.VoiceCallCount),
AiQueries = g.Sum(x => x.AiQueryCount),
AiTokens = g.Sum(x => x.AiTokenCount)
})
.OrderBy(r => r.Date)
.ToListAsync(cancellationToken);
return rows.Select(r => new UsageDayDto
{
Date = r.Date.ToDateTime(new TimeOnly(0, 0)),
SmsCount = r.Sms,
VoiceCallCount = r.Voice,
AiQueryCount = r.AiQueries,
AiTokenCount = r.AiTokens
}).ToList();
}
// ==================== Helpers ====================
/// <summary>
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیست‌های admin).
/// </summary>
private async Task<(Dictionary<int, Domain.TelemetryRecord> Latest, Dictionary<int, string> OwnerNames)> GetDeviceMetaAsync(
IReadOnlyList<int> deviceIds, CancellationToken cancellationToken)
{
if (deviceIds.Count == 0)
return (new Dictionary<int, Domain.TelemetryRecord>(), new Dictionary<int, string>());
var latestRows = await _dbContext.TelemetryRecords
.AsNoTracking()
.Where(t => deviceIds.Contains(t.DeviceId))
.GroupBy(t => t.DeviceId)
.Select(g => g.OrderByDescending(t => t.ServerTimestampUtc).First())
.ToListAsync(cancellationToken);
var latest = latestRows.ToDictionary(t => t.DeviceId);
var owners = await _dbContext.Devices
.Where(d => deviceIds.Contains(d.Id))
.Select(d => new { d.Id, Owner = d.User.Name + " " + d.User.Family })
.ToDictionaryAsync(x => x.Id, x => x.Owner, cancellationToken);
return (latest, owners);
}
private async Task<IReadOnlyList<AdminDeviceListItemDto>> BuildDeviceListAsync(
IReadOnlyList<int> deviceIds,
Dictionary<int, Domain.TelemetryRecord> latestMap,
CancellationToken cancellationToken)
{
if (deviceIds.Count == 0) return [];
var devices = await _dbContext.Devices.AsNoTracking()
.Include(d => d.User)
.Where(d => deviceIds.Contains(d.Id))
.ToListAsync(cancellationToken);
return devices.Select(d => ToDeviceListItem(d, latestMap)).ToList();
}
/// <summary>
/// Persian (۰-۹) و Arabic (٠-٩) digits را به آستانه‌ای انگلیسی تبدیل می‌کند تا search با ارقام فارسی هم کار کند.
/// (MobileNormalizer برای فرمت یکپارچه موبایل است و برای رشته‌ی general search قابل استفاده نیست.)
/// </summary>
private static string NormalizeDigits(string input)
{
var chars = input.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
var c = chars[i];
if (c is >= '۰' and <= '۹') chars[i] = (char)('0' + c - '۰');
else if (c is >= '٠' and <= '٩') chars[i] = (char)('0' + c - '٠');
}
return new string(chars);
}
private AdminDeviceListItemDto ToDeviceListItem(Domain.Device device, Dictionary<int, Domain.TelemetryRecord> latestMap)
{
latestMap.TryGetValue(device.Id, out var last);
var online = last != null && last.ServerTimestampUtc >= DateTime.UtcNow.AddMinutes(-_onlineWindowMinutes);
return new AdminDeviceListItemDto
{
Id = device.Id,
DeviceName = device.DeviceName,
Location = device.Location,
OwnerName = device.User.Name + " " + device.User.Family,
OwnerMobile = device.User.Mobile,
IsOnline = online,
LastTelemetryAt = last?.ServerTimestampUtc,
TemperatureC = last?.TemperatureC,
HumidityPercent = last?.HumidityPercent
};
}
}

View File

@@ -1,6 +1,7 @@
using GreenHome.Application;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.Usage;
using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak;
using Microsoft.EntityFrameworkCore;
@@ -21,17 +22,20 @@ public sealed class AlertNotificationSender
private readonly GreenHomeDbContext _dbContext;
private readonly ISmsService _smsService;
private readonly IVoiceCallService _voiceCallService;
private readonly UsageTracker _usageTracker;
private readonly ILogger<AlertNotificationSender> _logger;
public AlertNotificationSender(
GreenHomeDbContext dbContext,
ISmsService smsService,
IVoiceCallService voiceCallService,
UsageTracker usageTracker,
ILogger<AlertNotificationSender> logger)
{
_dbContext = dbContext;
_smsService = smsService;
_voiceCallService = voiceCallService;
_usageTracker = usageTracker;
_logger = logger;
}
@@ -80,7 +84,7 @@ public sealed class AlertNotificationSender
// Send
return await SendAsync(
user.Mobile, device.DeviceName, message,
user.Mobile, user.Id, device.DeviceName, message,
condition.NotificationType, cancellationToken);
}
@@ -94,11 +98,16 @@ public sealed class AlertNotificationSender
AlertNotificationType notificationType,
CancellationToken cancellationToken)
{
return await SendAsync(mobile, deviceName, message, notificationType, cancellationToken);
var user = await _dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
return await SendAsync(mobile, user?.Id ?? 0, deviceName, message, notificationType, cancellationToken);
}
private async Task<SendResult> SendAsync(
string mobile,
int userId,
string deviceName,
string message,
AlertNotificationType notificationType,
@@ -110,11 +119,11 @@ public sealed class AlertNotificationSender
{
if (notificationType == AlertNotificationType.SMS)
{
return await SendSmsAsync(mobile, deviceName, message, startTime, cancellationToken);
return await SendSmsAsync(mobile, userId, deviceName, message, startTime, cancellationToken);
}
else
{
return await SendCallAsync(mobile, deviceName, message, startTime, cancellationToken);
return await SendCallAsync(mobile, userId, deviceName, message, startTime, cancellationToken);
}
}
catch (Exception ex)
@@ -133,6 +142,7 @@ public sealed class AlertNotificationSender
private async Task<SendResult> SendSmsAsync(
string mobile,
int userId,
string deviceName,
string message,
DateTime startTime,
@@ -159,6 +169,7 @@ public sealed class AlertNotificationSender
{
var outboxIds = JsonSerializer.Serialize(smsResponse.Data.MessageOutboxIds);
_logger.LogInformation("Alert SMS sent: Mobile={Mobile}, OutboxIds={OutboxIds}", mobile, outboxIds);
await _usageTracker.RecordAsync(userId, UsageKind.Sms, cancellationToken: cancellationToken);
return new SendResult(true, outboxIds, null, processingTime);
}
@@ -178,6 +189,7 @@ public sealed class AlertNotificationSender
private async Task<SendResult> SendCallAsync(
string mobile,
int userId,
string deviceName,
string message,
DateTime startTime,
@@ -186,6 +198,7 @@ public sealed class AlertNotificationSender
try
{
_logger.LogInformation("Voice call alert requested: Mobile={Mobile}, Message={Message}", mobile, message);
// ponytail: صوتی فعلاً stub است؛ ثبت مصرف را وقتی call واقعی پیاده شد همان‌جا (بعد از success) اضافه کن
var processingTime = (long)(DateTime.UtcNow - startTime).TotalMilliseconds;
return new SendResult(true, null, "Voice call not yet implemented", processingTime);
}

View File

@@ -1,5 +1,6 @@
using GreenHome.Application;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.Usage;
using GreenHome.Sms.Ippanel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -13,6 +14,7 @@ public sealed class AuthService : IAuthService
{
private readonly GreenHomeDbContext _dbContext;
private readonly ISmsService _smsService;
private readonly UsageTracker _usageTracker;
private readonly ILogger<AuthService> _logger;
private readonly string _tokenSecret;
private const int CodeExpirationMinutes = 5;
@@ -21,11 +23,13 @@ public sealed class AuthService : IAuthService
public AuthService(
GreenHomeDbContext dbContext,
ISmsService smsService,
UsageTracker usageTracker,
IConfiguration configuration,
ILogger<AuthService> logger)
{
_dbContext = dbContext;
_smsService = smsService;
_usageTracker = usageTracker;
_logger = logger;
_tokenSecret = configuration["Auth:TokenSecret"] ?? "greenhome-dev-secret-change-me";
}
@@ -45,6 +49,19 @@ public sealed class AuthService : IAuthService
};
}
var existingUser = await _dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
if (existingUser != null && existingUser.IsDisabled)
{
return new SendCodeResponse
{
Success = false,
Message = "حساب شما غیرفعال شده است. لطفاً با پشتیبانی تماس بگیرید."
};
}
var canResend = await CanResendCodeAsync(mobile, cancellationToken);
if (!canResend)
{
@@ -98,6 +115,9 @@ public sealed class AuthService : IAuthService
Variables = new Dictionary<string, string> { { "code", code } }
}, cancellationToken);
if (existingUser != null)
await _usageTracker.RecordAsync(existingUser.Id, UsageKind.Sms, cancellationToken: cancellationToken);
_logger.LogInformation("Verification code sent to {Mobile}", mobile);
}
catch (Exception ex)
@@ -149,6 +169,15 @@ public sealed class AuthService : IAuthService
var user = await _dbContext.Users
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
if (user != null && user.IsDisabled)
{
return new VerifyCodeResponse
{
Success = false,
Message = "حساب شما غیرفعال شده است. لطفاً با پشتیبانی تماس بگیرید."
};
}
if (user == null)
{
user = new Domain.User
@@ -179,7 +208,8 @@ public sealed class AuthService : IAuthService
Mobile = user.Mobile,
Name = user.Name,
Family = user.Family,
Role = user.Role
Role = user.Role,
IsDisabled = user.IsDisabled
}
};
}
@@ -233,13 +263,17 @@ public sealed class AuthService : IAuthService
if (user == null)
return null;
if (user.IsDisabled)
return null;
return new UserDto
{
Id = user.Id,
Mobile = user.Mobile,
Name = user.Name,
Family = user.Family,
Role = user.Role
Role = user.Role,
IsDisabled = user.IsDisabled
};
}

View File

@@ -1,5 +1,6 @@
using GreenHome.Application;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.Usage;
using GreenHome.Sms.Ippanel;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -10,15 +11,18 @@ public sealed class DeviceTokenService : IDeviceTokenService
{
private readonly GreenHomeDbContext _dbContext;
private readonly ISmsService _smsService;
private readonly UsageTracker _usageTracker;
private readonly ILogger<DeviceTokenService> _logger;
public DeviceTokenService(
GreenHomeDbContext dbContext,
ISmsService smsService,
UsageTracker usageTracker,
ILogger<DeviceTokenService> logger)
{
_dbContext = dbContext;
_smsService = smsService;
_usageTracker = usageTracker;
_logger = logger;
}
@@ -76,6 +80,7 @@ public sealed class DeviceTokenService : IDeviceTokenService
try
{
var settings = await _dbContext.DeviceSettings
.Include(ds => ds.Device)
.FirstOrDefaultAsync(ds => ds.DevicePhoneNumber == request.DevicePhoneNumber, cancellationToken);
if (settings == null)
@@ -121,6 +126,7 @@ public sealed class DeviceTokenService : IDeviceTokenService
};
}
await _usageTracker.RecordAsync(settings.Device.UserId, UsageKind.Sms, cancellationToken: cancellationToken);
_logger.LogInformation("Token requested for device phone {PhoneNumber}", request.DevicePhoneNumber);
return new RequestDeviceTokenResponse
@@ -211,6 +217,7 @@ public sealed class DeviceTokenService : IDeviceTokenService
};
}
await _usageTracker.RecordAsync(settings.Device.UserId, UsageKind.Sms, cancellationToken: cancellationToken);
settings.TokenCode = null;
settings.VerificationCode = null;
settings.TokenExpiresAt = null;

View File

@@ -0,0 +1,69 @@
using GreenHome.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace GreenHome.Infrastructure.Services.Usage;
public enum UsageKind
{
Sms,
VoiceCall,
AiQuery
}
/// <summary>
/// افزایش‌دهنده شمارنده مصرف روزانه کاربران.
/// تک نقطه‌ای است که همه مسیرهای ارسال SMS / تماس صوتی / query AI از آن عبور می‌کنند.
/// خطا در شمارش مصرف نباید ارسال اعلان را خراب کند، بنابراین خطا فقط لاگ می‌شود.
/// </summary>
public sealed class UsageTracker
{
private readonly GreenHomeDbContext _dbContext;
private readonly ILogger<UsageTracker> _logger;
public UsageTracker(GreenHomeDbContext dbContext, ILogger<UsageTracker> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task RecordAsync(int userId, UsageKind kind, int tokens = 0, CancellationToken cancellationToken = default)
{
if (userId <= 0) return;
try
{
// ponytail: روز بر اساس UTC (DateOnly) — اگر در آینده نیاز به «روز شمسی» بود اینجا جایگزین می‌شود.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var counter = await _dbContext.UsageCounters
.FirstOrDefaultAsync(u => u.UserId == userId && u.Date == today, cancellationToken);
if (counter == null)
{
counter = new Domain.UsageCounter { UserId = userId, Date = today };
_dbContext.UsageCounters.Add(counter);
}
switch (kind)
{
case UsageKind.Sms:
counter.SmsCount++;
break;
case UsageKind.VoiceCall:
counter.VoiceCallCount++;
break;
case UsageKind.AiQuery:
counter.AiQueryCount++;
counter.AiTokenCount += tokens;
break;
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to record usage: UserId={UserId}, Kind={Kind}", userId, kind);
}
}
}

View File

@@ -1,6 +1,6 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import ServiceWorkerManager from '@/components/ServiceWorkerManager'
import { DeviceProvider, AuthGuard } from '@/components/layout'
import { DeviceProvider, AuthGuard, AdminGuard } from '@/components/layout'
import Home from '@/app/page'
import LoginPage from '@/app/login/page'
import VerifyCodePage from '@/app/verify-code/page'
@@ -12,6 +12,11 @@ import WeatherPage from '@/app/weather/page'
import ProfilePage from '@/app/profile/page'
import DevicePage from '@/app/device/page'
import GroupPage from '@/app/group/page'
import AdminLayout from '@/app/admin/AdminLayout'
import AdminDashboardPage from '@/app/admin/page'
import AdminUsersPage from '@/app/admin/users/page'
import AdminDevicesPage from '@/app/admin/devices/page'
import AdminUsagePage from '@/app/admin/usage/page'
function DeviceRedirect() {
const location = useLocation()
@@ -37,6 +42,12 @@ export default function App() {
<Route path="/device" element={<DevicePage />} />
<Route path="/group" element={<GroupPage />} />
<Route path="/device-settings" element={<DeviceRedirect />} />
<Route path="/admin" element={<AdminGuard><AdminLayout /></AdminGuard>}>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="devices" element={<AdminDevicesPage />} />
<Route path="usage" element={<AdminUsagePage />} />
</Route>
<Route path="/alert-settings" element={<DeviceRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -0,0 +1,83 @@
'use client'
import { Outlet, useLocation } from 'react-router-dom'
import { Link } from '@/lib/router'
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
{ href: '/admin/users', label: 'کاربران', icon: Users, exact: false },
{ href: '/admin/devices', label: 'دستگاه‌ها', icon: Cpu, exact: false },
{ href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false },
]
export default function AdminLayout() {
const pathname = useLocation().pathname
const isActive = (item: (typeof NAV_ITEMS)[number]) =>
item.exact ? pathname === item.href : pathname.startsWith(item.href)
const navLinks = (onClick?: () => void) =>
NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
onClick={onClick}
className={cn(
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
active
? 'bg-green-50 text-green-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
)}
>
<Icon className={cn('w-5 h-5', active ? 'text-green-600' : 'text-gray-400')} />
{item.label}
</Link>
)
})
return (
<div className="min-h-screen bg-[#fafafa] flex flex-col md:flex-row">
{/* Desktop sidebar (right side in RTL) */}
<aside className="hidden md:flex flex-col w-60 shrink-0 border-l border-gray-200 bg-white p-4 sticky top-0 h-screen">
<div className="px-3 py-4 mb-2 border-b border-gray-100">
<h1 className="text-base font-bold text-gray-900">پنل مدیریت</h1>
<p className="text-xs text-gray-400 mt-0.5">گ린‌هوم</p>
</div>
<nav className="flex-1 flex flex-col gap-1">{navLinks()}</nav>
<Link
href="/"
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
>
<ArrowRight className="w-5 h-5 text-gray-400" />
بازگشت به اپلیکیشن
</Link>
</aside>
{/* Mobile top bar */}
<header className="md:hidden sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<h1 className="text-sm font-bold text-gray-900">پنل مدیریت</h1>
<Link href="/" className="text-xs font-medium text-gray-500 flex items-center gap-1">
<ArrowRight className="w-3.5 h-3.5" />
خروج
</Link>
</div>
<nav className="flex gap-1 px-3 pb-2 overflow-x-auto">
{navLinks()}
</nav>
</header>
{/* Page content */}
<main className="flex-1 p-4 md:p-6">
<div className="max-w-5xl mx-auto">
<Outlet />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,231 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Cpu, Thermometer, Eye } from 'lucide-react'
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail } from '@/lib/api'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
import { SearchInput } from '@/components/forms/SearchInput'
import { Pagination } from '@/components/navigation/Pagination'
import { Badge } from '@/components/common/Badge'
import { Button } from '@/components/common/Button'
import { Modal } from '@/components/common/Modal'
import { EmptyState } from '@/components/common/EmptyState'
const PAGE_SIZE = 20
export default function AdminDevicesPage() {
const [result, setResult] = useState<PagedResult<AdminDeviceListItem> | null>(null)
const [search, setSearch] = useState('')
const [appliedSearch, setAppliedSearch] = useState('')
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [detail, setDetail] = useState<AdminDeviceDetail | null>(null)
const fetchDevices = useCallback(async (p: number, s: string) => {
setLoading(true)
setError('')
try {
const data = await api.adminDevices({ search: s || undefined, page: p, pageSize: PAGE_SIZE })
setResult(data)
} catch (e) {
setError((e as Error).message || 'خطا در دریافت دستگاه‌ها')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchDevices(page, appliedSearch)
}, [page, appliedSearch, fetchDevices])
const handleSearch = (value?: string) => {
const v = value ?? search
setSearch(v)
setAppliedSearch(v)
setPage(1)
}
const openDetail = async (id: number) => {
try {
setDetail(await api.adminDevice(id))
} catch (e) {
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه')
}
}
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
const showPagination = totalPages > 1
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-gray-900">دستگاه‌ها</h2>
{result && (
<span className="text-xs text-gray-500">
{toPersianDigits(result.totalCount)} دستگاه
</span>
)}
</div>
<SearchInput
value={search}
onValueChange={setSearch}
onSubmit={handleSearch}
onClear={() => handleSearch('')}
placeholder="جستجو بر اساس نام دستگاه یا مالک..."
/>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
{loading ? (
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="p-4 animate-pulse">
<div className="h-4 w-2/3 bg-gray-100 rounded" />
</div>
))}
</div>
) : result && result.items.length > 0 ? (
<>
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-200">
<th className="text-right font-medium py-3 px-4">دستگاه</th>
<th className="text-right font-medium py-3 px-4">موقعیت</th>
<th className="text-right font-medium py-3 px-4">مالک</th>
<th className="text-left font-medium py-3 px-4">دما</th>
<th className="text-left font-medium py-3 px-4">رطوبت</th>
<th className="text-right font-medium py-3 px-4">آخرین تله‌متری</th>
<th className="text-right font-medium py-3 px-4">وضعیت</th>
<th className="text-right font-medium py-3 px-4"></th>
</tr>
</thead>
<tbody>
{result.items.map((device) => (
<tr key={device.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
<td className="py-3 px-4 font-medium text-gray-900">{device.deviceName}</td>
<td className="py-3 px-4 text-gray-500">{device.location || '—'}</td>
<td className="py-3 px-4">
<div className="text-gray-900">{device.ownerName}</div>
<div className="text-xs text-gray-400" dir="ltr">{toPersianDigits(device.ownerMobile)}</div>
</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">
{device.temperatureC != null ? `${toPersianDigits(device.temperatureC)}°` : '—'}
</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">
{device.humidityPercent != null ? `${toPersianDigits(device.humidityPercent)}٪` : '—'}
</td>
<td className="py-3 px-4 text-gray-500">
{device.lastTelemetryAt ? formatPersianDateFromIso(device.lastTelemetryAt) : '—'}
</td>
<td className="py-3 px-4">
{device.isOnline
? <Badge variant="success" size="sm">آنلاین</Badge>
: <Badge variant="default" size="sm">آفلاین</Badge>}
</td>
<td className="py-3 px-4">
<Button variant="outline" size="sm" icon={Eye} onClick={() => openDetail(device.id)}>
جزئیات
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showPagination && (
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
)}
</>
) : (
<EmptyState
icon={Cpu}
title="دستگاهی یافت نشد"
message="هنوز دستگاهی ثبت نشده یا جستجوی شما نتیجه‌ای ندارد."
/>
)}
<Modal isOpen={detail !== null} onClose={() => setDetail(null)} title={detail ? `دستگاه: ${detail.deviceName}` : ''} size="lg">
{detail && (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">مالک</p>
<p className="font-medium text-gray-900">{detail.ownerName} {detail.ownerFamily}</p>
<p className="text-xs text-gray-400" dir="ltr">{toPersianDigits(detail.ownerMobile)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">وضعیت</p>
{detail.isOnline
? <Badge variant="success" size="sm">آنلاین</Badge>
: <Badge variant="default" size="sm">آفلاین</Badge>}
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">شماره دستگاه</p>
<p className="font-medium text-gray-900" dir="ltr">{detail.devicePhoneNumber ? toPersianDigits(detail.devicePhoneNumber) : '—'}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">اعضا</p>
<p className="font-medium text-gray-900">{toPersianDigits(detail.memberCount)} نفر</p>
</div>
{detail.productType && (
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">نوع محصول</p>
<p className="font-medium text-gray-900">{detail.productType}</p>
</div>
)}
{detail.uploadIntervalMin != null && (
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">بازه بارگذاری</p>
<p className="font-medium text-gray-900">{toPersianDigits(detail.uploadIntervalMin)} دقیقه</p>
</div>
)}
{detail.relayCount != null && (
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">تعداد رله</p>
<p className="font-medium text-gray-900">{toPersianDigits(detail.relayCount)}</p>
</div>
)}
</div>
<div>
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Thermometer className="w-4 h-4 text-gray-400" />
آخرین تله‌متری‌ها ({toPersianDigits(detail.lastTelemetry.length)})
</h4>
{detail.lastTelemetry.length === 0 ? (
<p className="text-gray-400 text-xs">داده‌ای ثبت نشده است.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b border-gray-100">
<th className="text-right font-medium py-2 px-3">تاریخ</th>
<th className="text-left font-medium py-2 px-3">دما (°)</th>
<th className="text-left font-medium py-2 px-3">رطوبت (٪)</th>
<th className="text-left font-medium py-2 px-3">خاک (٪)</th>
</tr>
</thead>
<tbody>
{detail.lastTelemetry.map((t) => (
<tr key={t.id} className="border-b border-gray-50 last:border-0">
<td className="py-2 px-3 text-gray-600">{formatPersianDateFromIso(t.timestampUtc)}</td>
<td className="py-2 px-3 text-left tabular-nums">{toPersianDigits(t.temperatureC)}</td>
<td className="py-2 px-3 text-left tabular-nums">{toPersianDigits(t.humidityPercent)}</td>
<td className="py-2 px-3 text-left tabular-nums">{toPersianDigits(t.soilPercent)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useEffect, useState } from 'react'
import { Users, Cpu, Wifi, WifiOff, Bell, MessageSquare, Bot } from 'lucide-react'
import { api, AdminDashboard, AdminUsageDay } from '@/lib/api'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { gregorianToPersian } from '@/lib/date/persian-date'
import { formatPersianDate } from '@/lib/format/persian-date'
const MONTH_NAMES = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
function KpiCard({ icon: Icon, label, value, accent = false }: {
icon: typeof Users
label: string
value: number
accent?: boolean
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${accent ? 'bg-green-50' : 'bg-gray-50'}`}>
<Icon className={`w-5 h-5 ${accent ? 'text-green-600' : 'text-gray-500'}`} />
</div>
<div>
<p className="text-xl font-bold text-gray-900 leading-tight">{toPersianDigits(value)}</p>
<p className="text-xs text-gray-500">{label}</p>
</div>
</div>
</div>
)
}
function SmsSparkline({ data }: { data: AdminUsageDay[] }) {
const values = data.map((d) => d.smsCount)
const max = Math.max(...values, 1)
const W = 280
const H = 56
const step = W / Math.max(values.length - 1, 1)
const points = values.map((v, i) => {
const x = i * step
const y = H - (v / max) * (H - 8) - 4
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
const labels = data.map((d) => {
const p = gregorianToPersian(new Date(d.date))
return `${MONTH_NAMES[p.month - 1]} ${toPersianDigits(p.day)}`
})
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">پیامک‌های ۷ روز اخیر</h3>
<div dir="ltr">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-14" preserveAspectRatio="none">
<polyline points={points} fill="none" stroke="#16a34a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className="flex justify-between mt-2 text-[10px] text-gray-400">
{labels.map((l, i) => (
<span key={i}>{i % 2 === 0 ? l : ''}</span>
))}
</div>
</div>
</div>
)
}
export default function AdminDashboardPage() {
const [data, setData] = useState<AdminDashboard | null>(null)
const [error, setError] = useState('')
useEffect(() => {
api.adminDashboard()
.then(setData)
.catch((e) => setError(e.message || 'خطا در دریافت اطلاعات'))
}, [])
if (error) {
return <p className="text-sm text-red-600 bg-red-50 rounded-xl p-4">{error}</p>
}
const today = gregorianToPersian(new Date())
const todayStr = formatPersianDate(today.year, today.month, today.day)
if (!data) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-4 animate-pulse">
<div className="w-full h-16 bg-gray-100 rounded-lg" />
</div>
))}
</div>
)
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard icon={Users} label="کاربران" value={data.totalUsers} />
<KpiCard icon={Cpu} label="دستگاه‌ها" value={data.totalDevices} />
<KpiCard icon={Wifi} label="آنلاین" value={data.onlineDevices} accent />
<KpiCard icon={WifiOff} label="آفلاین" value={data.offlineDevices} />
<KpiCard icon={Bell} label="هشدار (۲۴ ساعت)" value={data.alertsLast24h} />
<KpiCard icon={MessageSquare} label="پیامک (۷ روز)" value={data.smsLast7Days} />
<KpiCard icon={Bot} label="درخواست هوش مصنوعی (۷ روز)" value={data.aiQueriesLast7Days} />
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4 flex flex-col justify-between">
<p className="text-xs text-gray-500">امروز</p>
<p className="text-sm font-semibold text-gray-900 mt-1">{todayStr}</p>
</div>
</div>
<SmsSparkline data={data.smsDailyLast7Days} />
</div>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
import { BarChart3, MessageSquare, Phone, Bot, Hash } from 'lucide-react'
import { api, AdminUsageDay } from '@/lib/api'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
import { Button } from '@/components/common/Button'
import { EmptyState } from '@/components/common/EmptyState'
type RangePreset = 7 | 30 | 90
function toIsoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function daysAgo(n: number): string {
const d = new Date()
d.setDate(d.getDate() - n)
return toIsoDate(d)
}
export default function AdminUsagePage() {
const [days, setDays] = useState<AdminUsageDay[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [from, setFrom] = useState(daysAgo(29))
const [to, setTo] = useState(toIsoDate(new Date()))
const [preset, setPreset] = useState<RangePreset | null>(30)
const fetchUsage = useCallback(async (f: string, t: string) => {
setLoading(true)
setError('')
try {
const data = await api.adminUsage({ from: f, to: t })
setDays(data)
} catch (e) {
setError((e as Error).message || 'خطا در دریافت مصرف')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUsage(from, to)
}, [from, to, fetchUsage])
const applyPreset = (n: RangePreset) => {
setPreset(n)
setFrom(daysAgo(n - 1))
setTo(toIsoDate(new Date()))
}
const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
setter(e.target.value)
setPreset(null)
}
const totals = days.reduce(
(acc, d) => ({
sms: acc.sms + d.smsCount,
calls: acc.calls + d.voiceCallCount,
queries: acc.queries + d.aiQueryCount,
tokens: acc.tokens + d.aiTokenCount,
}),
{ sms: 0, calls: 0, queries: 0, tokens: 0 }
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2 className="text-base font-bold text-gray-900">مصرف</h2>
<div className="flex items-center gap-2">
<input
type="date"
value={from}
onChange={onDateChange(setFrom)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
/>
<span className="text-xs text-gray-400">تا</span>
<input
type="date"
value={to}
onChange={onDateChange(setTo)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
/>
</div>
</div>
<div className="flex items-center gap-2">
{([7, 30, 90] as RangePreset[]).map((n) => (
<Button
key={n}
size="sm"
variant={preset === n ? 'success' : 'default'}
onClick={() => applyPreset(n)}
>
{toPersianDigits(n)} روز اخیر
</Button>
))}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<SummaryCard icon={MessageSquare} label="کل پیامک" value={totals.sms} />
<SummaryCard icon={Phone} label="کل تماس صوتی" value={totals.calls} />
<SummaryCard icon={Bot} label="کل درخواست AI" value={totals.queries} />
<SummaryCard icon={Hash} label="کل توکن AI" value={totals.tokens} />
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
{loading ? (
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="p-4 animate-pulse">
<div className="h-4 w-1/3 bg-gray-100 rounded" />
</div>
))}
</div>
) : days.length > 0 ? (
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-200">
<th className="text-right font-medium py-3 px-4">تاریخ</th>
<th className="text-left font-medium py-3 px-4">پیامک</th>
<th className="text-left font-medium py-3 px-4">تماس صوتی</th>
<th className="text-left font-medium py-3 px-4">درخواست AI</th>
<th className="text-left font-medium py-3 px-4">توکن AI</th>
</tr>
</thead>
<tbody>
{[...days].reverse().map((d) => (
<tr key={d.date} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
<td className="py-3 px-4 font-medium text-gray-900">{formatPersianDateFromIso(d.date)}</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">{toPersianDigits(d.smsCount)}</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">{toPersianDigits(d.voiceCallCount)}</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">{toPersianDigits(d.aiQueryCount)}</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">{toPersianDigits(d.aiTokenCount)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
icon={BarChart3}
title="داده‌ای ثبت نشده"
message="در بازه انتخابی مصرفی ثبت نشده است."
/>
)}
</div>
)
}
function SummaryCard({ icon: Icon, label, value }: { icon: typeof BarChart3; label: string; value: number }) {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center">
<Icon className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-xl font-bold text-gray-900 leading-tight">{toPersianDigits(value)}</p>
<p className="text-xs text-gray-500">{label}</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,229 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Users, ShieldCheck, ShieldOff, Cpu, UserX } from 'lucide-react'
import { api, PagedResult, AdminUserListItem, AdminUserDetail } from '@/lib/api'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
import { SearchInput } from '@/components/forms/SearchInput'
import { Pagination } from '@/components/navigation/Pagination'
import { Badge } from '@/components/common/Badge'
import { Button } from '@/components/common/Button'
import { Modal } from '@/components/common/Modal'
import { EmptyState } from '@/components/common/EmptyState'
const PAGE_SIZE = 20
const ROLE_NAMES: Record<number, string> = { 0: 'کاربر', 1: 'مدیر', 2: 'ناظر' }
const ROLE_VARIANTS: Record<number, 'info' | 'success' | 'warning'> = { 0: 'default' as never, 1: 'success', 2: 'warning' }
export default function AdminUsersPage() {
const [result, setResult] = useState<PagedResult<AdminUserListItem> | null>(null)
const [search, setSearch] = useState('')
const [appliedSearch, setAppliedSearch] = useState('')
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [detail, setDetail] = useState<AdminUserDetail | null>(null)
const [busyId, setBusyId] = useState<number | null>(null)
const fetchUsers = useCallback(async (p: number, s: string) => {
setLoading(true)
setError('')
try {
const data = await api.adminUsers({ search: s || undefined, page: p, pageSize: PAGE_SIZE })
setResult(data)
} catch (e) {
setError((e as Error).message || 'خطا در دریافت کاربران')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUsers(page, appliedSearch)
}, [page, appliedSearch, fetchUsers])
const handleSearch = (value?: string) => {
const v = value ?? search
setSearch(v)
setAppliedSearch(v)
setPage(1)
}
const openDetail = async (id: number) => {
try {
setDetail(await api.adminUser(id))
} catch (e) {
setError((e as Error).message || 'خطا در دریافت جزئیات کاربر')
}
}
const runAction = async (id: number, action: () => Promise<unknown>) => {
setBusyId(id)
setError('')
try {
await action()
await fetchUsers(page, appliedSearch)
} catch (e) {
setError((e as Error).message || 'خطا در انجام عملیات')
} finally {
setBusyId(null)
}
}
const setRole = (id: number, role: number) =>
runAction(id, () => api.adminSetUserRole(id, role))
const toggleStatus = (user: AdminUserListItem) =>
runAction(user.id, () => api.adminSetUserStatus(user.id, user.isDisabled))
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
const showPagination = totalPages > 1
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-gray-900">کاربران</h2>
{result && (
<span className="text-xs text-gray-500">
{toPersianDigits(result.totalCount)} کاربر
</span>
)}
</div>
<SearchInput
value={search}
onValueChange={setSearch}
onSubmit={handleSearch}
onClear={() => handleSearch('')}
placeholder="جستجو بر اساس نام یا شماره موبایل..."
/>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
{loading ? (
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="p-4 animate-pulse">
<div className="h-4 w-2/3 bg-gray-100 rounded" />
</div>
))}
</div>
) : result && result.items.length > 0 ? (
<>
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-200">
<th className="text-right font-medium py-3 px-4">کاربر</th>
<th className="text-right font-medium py-3 px-4">نقش</th>
<th className="text-left font-medium py-3 px-4">دستگاه</th>
<th className="text-right font-medium py-3 px-4">آخرین ورود</th>
<th className="text-right font-medium py-3 px-4">وضعیت</th>
<th className="text-right font-medium py-3 px-4">عملیات</th>
</tr>
</thead>
<tbody>
{result.items.map((user) => (
<tr key={user.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
<td className="py-3 px-4">
<div className="font-medium text-gray-900">{user.name} {user.family}</div>
<div className="text-xs text-gray-400" dir="ltr">{toPersianDigits(user.mobile)}</div>
</td>
<td className="py-3 px-4">
<Badge variant={ROLE_VARIANTS[user.role] ?? 'default'} size="sm">
{ROLE_NAMES[user.role] ?? 'کاربر'}
</Badge>
</td>
<td className="py-3 px-4 text-left tabular-nums text-gray-700">{toPersianDigits(user.deviceCount)}</td>
<td className="py-3 px-4 text-gray-500">
{user.lastLoginAt ? formatPersianDateFromIso(user.lastLoginAt) : '—'}
</td>
<td className="py-3 px-4">
{user.isDisabled
? <Badge variant="warning" size="sm" icon={UserX}>غیرفعال</Badge>
: <Badge variant="success" size="sm">فعال</Badge>}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" icon={ShieldCheck} disabled={busyId === user.id}
onClick={() => setRole(user.id, user.role === 1 ? 0 : 1)}
tooltip={user.role === 1 ? 'حذف مدیریتی' : 'اعلام مدیر'}>
{user.role === 1 ? 'مدیر' : 'اعلام'}
</Button>
<Button variant="ghost" size="sm" icon={ShieldOff} disabled={busyId === user.id}
onClick={() => toggleStatus(user)}>
{user.isDisabled ? 'فعال‌سازی' : 'غیرفعال'}
</Button>
<Button variant="outline" size="sm" icon={Cpu} onClick={() => openDetail(user.id)}>
جزئیات
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showPagination && (
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
)}
</>
) : (
<EmptyState
icon={Users}
title="کاربری یافت نشد"
message="هنوز کاربری ثبت نشده یا جستجوی شما نتیجه‌ای ندارد."
/>
)}
<Modal isOpen={detail !== null} onClose={() => setDetail(null)} title={detail ? `کاربر: ${detail.name} ${detail.family}` : ''} size="lg">
{detail && (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">موبایل</p>
<p className="font-medium text-gray-900" dir="ltr">{toPersianDigits(detail.mobile)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">تاریخ عضویت</p>
<p className="font-medium text-gray-900">{formatPersianDateFromIso(detail.createdAt)}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">نقش</p>
<p className="font-medium text-gray-900">{ROLE_NAMES[detail.role] ?? 'کاربر'}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500">وضعیت</p>
<p className="font-medium text-gray-900">{detail.isDisabled ? 'غیرفعال' : 'فعال'}</p>
</div>
</div>
<div>
<h4 className="font-semibold text-gray-900 mb-2">
دستگاه‌ها ({toPersianDigits(detail.devices.length)})
</h4>
{detail.devices.length === 0 ? (
<p className="text-gray-400 text-xs">دستگاهی ندارد.</p>
) : (
<div className="space-y-2">
{detail.devices.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded-lg border border-gray-100 p-3">
<div>
<p className="font-medium text-gray-900">{d.deviceName}</p>
<p className="text-xs text-gray-400">{d.location}</p>
</div>
{d.isOnline
? <Badge variant="success" size="sm">آنلاین</Badge>
: <Badge variant="default" size="sm">آفلاین</Badge>}
</div>
))}
</div>
)}
</div>
</div>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
const ADMIN_ROLE = 1
function isAdmin(): boolean {
if (typeof window === 'undefined') return false
try {
const raw = localStorage.getItem('user')
if (!raw) return false
const user = JSON.parse(raw) as { role?: number }
return user.role === ADMIN_ROLE
} catch {
return false
}
}
export function AdminGuard({ children }: { children: ReactNode }) {
if (!isAdmin()) {
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@@ -3,3 +3,4 @@ export { BottomNav } from './BottomNav'
export { TopHeader } from './TopHeader'
export { DeviceProvider, useDevice } from './DeviceProvider'
export { AuthGuard } from './AuthGuard'
export { AdminGuard } from './AdminGuard'

View File

@@ -18,6 +18,12 @@ import type {
UpsertDeviceMemberRequest,
DevicePostDto,
DevicePostImageDto,
AdminDashboard,
AdminUserListItem,
AdminUserDetail,
AdminDeviceListItem,
AdminDeviceDetail,
AdminUsageDay,
} from './types'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -203,4 +209,34 @@ export const api = {
method: 'POST',
body: JSON.stringify({ deviceId, authorUserId }),
}),
// Admin
adminDashboard: () => http<AdminDashboard>(`${API_BASE}/api/admin/dashboard`),
adminUsers: (q: { search?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams()
if (q.search) params.set('search', q.search)
if (q.page) params.set('page', String(q.page))
if (q.pageSize) params.set('pageSize', String(q.pageSize))
return http<PagedResult<AdminUserListItem>>(`${API_BASE}/api/admin/users?${params.toString()}`)
},
adminUser: (id: number) => http<AdminUserDetail>(`${API_BASE}/api/admin/users/${id}`),
adminSetUserRole: (id: number, role: number) =>
http<void>(`${API_BASE}/api/admin/users/${id}/role`, { method: 'PUT', body: JSON.stringify({ role }) }),
adminSetUserStatus: (id: number, isEnabled: boolean) =>
http<void>(`${API_BASE}/api/admin/users/${id}/status`, { method: 'PUT', body: JSON.stringify({ isEnabled }) }),
adminDevices: (q: { search?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams()
if (q.search) params.set('search', q.search)
if (q.page) params.set('page', String(q.page))
if (q.pageSize) params.set('pageSize', String(q.pageSize))
return http<PagedResult<AdminDeviceListItem>>(`${API_BASE}/api/admin/devices?${params.toString()}`)
},
adminDevice: (id: number) => http<AdminDeviceDetail>(`${API_BASE}/api/admin/devices/${id}`),
adminUsage: (q: { from?: string; to?: string; userId?: number }) => {
const params = new URLSearchParams()
if (q.from) params.set('from', q.from)
if (q.to) params.set('to', q.to)
if (q.userId) params.set('userId', String(q.userId))
return http<AdminUsageDay[]>(`${API_BASE}/api/admin/usage?${params.toString()}`)
},
}

View File

@@ -275,3 +275,70 @@ export type DevicePostDto = {
updatedAt?: string
}
// ==================== Admin ====================
export type AdminDashboard = {
totalUsers: number
totalDevices: number
onlineDevices: number
offlineDevices: number
alertsLast24h: number
smsLast7Days: number
aiQueriesLast7Days: number
smsDailyLast7Days: AdminUsageDay[]
}
export type AdminUserListItem = {
id: number
name: string
family: string
mobile: string
role: number
isDisabled: boolean
deviceCount: number
lastLoginAt?: string
createdAt: string
}
export type AdminUserDetail = AdminUserListItem & {
devices: AdminDeviceListItem[]
}
export type AdminDeviceListItem = {
id: number
deviceName: string
location: string
ownerName: string
ownerMobile: string
isOnline: boolean
lastTelemetryAt?: string
temperatureC?: number
humidityPercent?: number
}
export type AdminDeviceDetail = {
id: number
deviceName: string
location: string
ownerName: string
ownerFamily: string
ownerMobile: string
isOnline: boolean
memberCount: number
productType?: string
province?: string
city?: string
devicePhoneNumber?: string
uploadIntervalMin?: number
relayCount?: number
lastTelemetry: TelemetryDto[]
}
export type AdminUsageDay = {
date: string
smsCount: number
voiceCallCount: number
aiQueryCount: number
aiTokenCount: number
}

View File

@@ -7,6 +7,7 @@ import {
isLeapJalaaliYear,
jalaaliMonthLength,
} from 'jalaali-js'
import { formatPersianDate } from '@/lib/format/persian-date'
export interface PersianDate {
year: number
@@ -24,6 +25,18 @@ export function persianToGregorian(year: number, month: number, day: number): Da
return new Date(gy, gm - 1, gd)
}
/**
* Format an ISO timestamp (or date string) to local Persian "yyyy/MM/dd".
* Returns an empty string for invalid dates.
*/
export function formatPersianDateFromIso(isoString: string): string {
if (!isoString) return ''
const date = new Date(isoString)
if (isNaN(date.getTime())) return ''
const { year, month, day } = gregorianToPersian(date)
return formatPersianDate(year, month, day)
}
export function getCurrentPersianDate(): PersianDate {
return gregorianToPersian(new Date())
}