initialize admin panel and necessry pages
This commit is contained in:
126
src/GreenHome.Api/Controllers/AdminController.cs
Normal file
126
src/GreenHome.Api/Controllers/AdminController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/GreenHome.Api/Filters/AdminAuthFilter.cs
Normal file
60
src/GreenHome.Api/Filters/AdminAuthFilter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
"TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS"
|
||||
},
|
||||
|
||||
"Admin": {
|
||||
"OnlineWindowMinutes": 15
|
||||
},
|
||||
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
129
src/GreenHome.Application/Dtos/Admin/AdminDtos.cs
Normal file
129
src/GreenHome.Application/Dtos/Admin/AdminDtos.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
15
src/GreenHome.Application/Interfaces/IAdminService.cs
Normal file
15
src/GreenHome.Application/Interfaces/IAdminService.cs
Normal 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);
|
||||
}
|
||||
21
src/GreenHome.Domain/UsageCounter.cs
Normal file
21
src/GreenHome.Domain/UsageCounter.cs
Normal 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; }
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
1548
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs
generated
Normal file
1548
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
399
src/GreenHome.Infrastructure/Services/Admin/AdminService.cs
Normal file
399
src/GreenHome.Infrastructure/Services/Admin/AdminService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
src/GreenHome.Infrastructure/Services/Usage/UsageTracker.cs
Normal file
69
src/GreenHome.Infrastructure/Services/Usage/UsageTracker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
83
src/GreenHome.Web/src/app/admin/AdminLayout.tsx
Normal file
83
src/GreenHome.Web/src/app/admin/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
src/GreenHome.Web/src/app/admin/devices/page.tsx
Normal file
231
src/GreenHome.Web/src/app/admin/devices/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
src/GreenHome.Web/src/app/admin/page.tsx
Normal file
114
src/GreenHome.Web/src/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
src/GreenHome.Web/src/app/admin/usage/page.tsx
Normal file
170
src/GreenHome.Web/src/app/admin/usage/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
src/GreenHome.Web/src/app/admin/users/page.tsx
Normal file
229
src/GreenHome.Web/src/app/admin/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/GreenHome.Web/src/components/layout/AdminGuard.tsx
Normal file
25
src/GreenHome.Web/src/components/layout/AdminGuard.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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()}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user