admin page phaze2
This commit is contained in:
@@ -123,4 +123,96 @@ public sealed class AdminController : ControllerBase
|
|||||||
var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken);
|
var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Alerts ====================
|
||||||
|
|
||||||
|
[HttpGet("alerts")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminAlertItemDto>>> Alerts(
|
||||||
|
[FromQuery] int? deviceId,
|
||||||
|
[FromQuery] Domain.AlertStatus? status,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminAlertFilter
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Status = status,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetAlertsAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Posts ====================
|
||||||
|
|
||||||
|
[HttpGet("posts")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
||||||
|
[FromQuery] int? deviceId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminPostFilter
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetPostsAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("posts/{id:int}")]
|
||||||
|
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var deleted = await _adminService.DeletePostAsync(id, currentAdminId, cancellationToken);
|
||||||
|
return deleted ? Ok(new { success = true }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Device Debug ====================
|
||||||
|
|
||||||
|
[HttpPost("devices/{id:int}/test-sms")]
|
||||||
|
public async Task<ActionResult<AdminDeviceTestResultDto>> TestDeviceSms(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.TestDeviceSmsAsync(id, currentAdminId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("devices/{id:int}/test-voice")]
|
||||||
|
public async Task<ActionResult<AdminDeviceTestResultDto>> TestDeviceVoice(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.TestDeviceVoiceAsync(id, currentAdminId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
|
[HttpGet("audit-logs")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminAuditLogDto>>> AuditLogs(
|
||||||
|
[FromQuery] int? adminId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminAuditLogFilter
|
||||||
|
{
|
||||||
|
AdminId = adminId,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,3 +127,77 @@ public sealed class UsageDayDto
|
|||||||
public int AiQueryCount { get; set; }
|
public int AiQueryCount { get; set; }
|
||||||
public int AiTokenCount { get; set; }
|
public int AiTokenCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Alerts ====================
|
||||||
|
|
||||||
|
public sealed class AdminAlertFilter
|
||||||
|
{
|
||||||
|
public int? DeviceId { get; set; }
|
||||||
|
public Domain.AlertStatus? Status { get; set; }
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminAlertItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string OwnerName { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public Domain.AlertNotificationType NotificationType { get; set; }
|
||||||
|
public Domain.AlertStatus Status { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime SentAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Posts ====================
|
||||||
|
|
||||||
|
public sealed class AdminPostFilter
|
||||||
|
{
|
||||||
|
public int? DeviceId { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminPostItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public Domain.DevicePostType PostType { get; set; }
|
||||||
|
public int ImageCount { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Device Debug ====================
|
||||||
|
|
||||||
|
public sealed class AdminDeviceTestResultDto
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
|
public sealed class AdminAuditLogFilter
|
||||||
|
{
|
||||||
|
public int? AdminId { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminAuditLogDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int AdminUserId { get; set; }
|
||||||
|
public string AdminName { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string TargetId { get; set; } = string.Empty;
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
public DateTime At { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,4 +12,10 @@ public interface IAdminService
|
|||||||
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
|
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
|
||||||
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
|
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/GreenHome.Domain/AdminAuditLog.cs
Normal file
35
src/GreenHome.Domain/AdminAuditLog.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// لاگ عملیات مدیریتی پنل admin (تغییر نقش/وضعیت کاربر، حذف پست، تست SMS/صوتی و ...)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminAuditLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه مدیری که عملیات را انجام داده
|
||||||
|
/// </summary>
|
||||||
|
public int AdminUserId { get; set; }
|
||||||
|
public User AdminUser { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام عملیات به فرمت «Entity.Action» (مثلاً User.SetRole, Post.Delete, Device.TestSms)
|
||||||
|
/// </summary>
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه هدف (id کاربر/دستگاه/پست)
|
||||||
|
/// </summary>
|
||||||
|
public string TargetId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// جزئیات اضافی (JSON اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// زمان عملیات (UTC)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime At { get; set; }
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
|
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
|
||||||
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
|
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
|
||||||
public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>();
|
public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>();
|
||||||
|
public DbSet<Domain.AdminAuditLog> AdminAuditLogs => Set<Domain.AdminAuditLog>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -458,5 +459,20 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.HasIndex(x => new { x.UserId, x.Date }).IsUnique();
|
b.HasIndex(x => new { x.UserId, x.Date }).IsUnique();
|
||||||
b.HasIndex(x => x.Date);
|
b.HasIndex(x => x.Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.AdminAuditLog>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("AdminAuditLogs");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.Action).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.TargetId).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.Details).HasMaxLength(2000);
|
||||||
|
b.HasOne(x => x.AdminUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.AdminUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
b.HasIndex(x => x.At);
|
||||||
|
b.HasIndex(x => x.AdminUserId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1597
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs
generated
Normal file
1597
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AdminPanelPhase2 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AdminAuditLogs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
AdminUserId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Action = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
TargetId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Details = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||||
|
At = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AdminAuditLogs", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AdminAuditLogs_Users_AdminUserId",
|
||||||
|
column: x => x.AdminUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AdminAuditLogs_AdminUserId",
|
||||||
|
table: "AdminAuditLogs",
|
||||||
|
column: "AdminUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AdminAuditLogs_At",
|
||||||
|
table: "AdminAuditLogs",
|
||||||
|
column: "At");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AdminAuditLogs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,44 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("AIQueries", (string)null);
|
b.ToTable("AIQueries", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("AdminUserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("At")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AdminUserId");
|
||||||
|
|
||||||
|
b.HasIndex("At");
|
||||||
|
|
||||||
|
b.ToTable("AdminAuditLogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1173,6 +1211,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GreenHome.Domain.User", "AdminUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AdminUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("AdminUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GreenHome.Domain.Device", "Device")
|
b.HasOne("GreenHome.Domain.Device", "Device")
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<NuGetAudit>false</NuGetAudit>
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using GreenHome.Application.Dtos.Admin;
|
using GreenHome.Application.Dtos.Admin;
|
||||||
using GreenHome.Domain;
|
using GreenHome.Domain;
|
||||||
using GreenHome.Infrastructure.Data;
|
using GreenHome.Infrastructure.Data;
|
||||||
|
using GreenHome.Infrastructure.Services.Usage;
|
||||||
|
using GreenHome.Sms.Ippanel;
|
||||||
|
using GreenHome.VoiceCall.Avanak;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
@@ -12,12 +17,27 @@ public sealed class AdminService : IAdminService
|
|||||||
{
|
{
|
||||||
private readonly GreenHomeDbContext _dbContext;
|
private readonly GreenHomeDbContext _dbContext;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
private readonly ISmsService _smsService;
|
||||||
|
private readonly IVoiceCallService _voiceCallService;
|
||||||
|
private readonly UsageTracker _usageTracker;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
private readonly int _onlineWindowMinutes;
|
private readonly int _onlineWindowMinutes;
|
||||||
|
|
||||||
public AdminService(GreenHomeDbContext dbContext, IMapper mapper, IConfiguration configuration)
|
public AdminService(
|
||||||
|
GreenHomeDbContext dbContext,
|
||||||
|
IMapper mapper,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ISmsService smsService,
|
||||||
|
IVoiceCallService voiceCallService,
|
||||||
|
UsageTracker usageTracker,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_smsService = smsService;
|
||||||
|
_voiceCallService = voiceCallService;
|
||||||
|
_usageTracker = usageTracker;
|
||||||
|
_environment = environment;
|
||||||
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
|
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +193,8 @@ public sealed class AdminService : IAdminService
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.Role = role;
|
user.Role = role;
|
||||||
|
AddAuditLog(currentAdminId, "User.SetRole", id.ToString(),
|
||||||
|
JsonSerializer.Serialize(new { role = role.ToString(), roleId = (int)role }));
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return UserMutationOutcome.Ok;
|
return UserMutationOutcome.Ok;
|
||||||
}
|
}
|
||||||
@@ -193,6 +215,7 @@ public sealed class AdminService : IAdminService
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.IsDisabled = !isEnabled;
|
user.IsDisabled = !isEnabled;
|
||||||
|
AddAuditLog(currentAdminId, isEnabled ? "User.Enable" : "User.Disable", id.ToString());
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
return UserMutationOutcome.Ok;
|
return UserMutationOutcome.Ok;
|
||||||
}
|
}
|
||||||
@@ -319,8 +342,280 @@ public sealed class AdminService : IAdminService
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Alerts ====================
|
||||||
|
|
||||||
|
public async Task<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (filter.Page <= 0) filter.Page = 1;
|
||||||
|
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||||||
|
|
||||||
|
IQueryable<Domain.AlertLog> query = _dbContext.AlertLogs.AsNoTracking()
|
||||||
|
.Include(a => a.Device).ThenInclude(d => d.User);
|
||||||
|
|
||||||
|
if (filter.DeviceId.HasValue)
|
||||||
|
query = query.Where(a => a.DeviceId == filter.DeviceId.Value);
|
||||||
|
if (filter.Status.HasValue)
|
||||||
|
query = query.Where(a => a.Status == filter.Status.Value);
|
||||||
|
if (filter.From.HasValue)
|
||||||
|
query = query.Where(a => a.SentAt >= filter.From.Value);
|
||||||
|
if (filter.To.HasValue)
|
||||||
|
query = query.Where(a => a.SentAt <= filter.To.Value);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(a => a.SentAt)
|
||||||
|
.ThenByDescending(a => a.Id)
|
||||||
|
.Skip((filter.Page - 1) * filter.PageSize)
|
||||||
|
.Take(filter.PageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new PagedResult<AdminAlertItemDto>
|
||||||
|
{
|
||||||
|
Items = items.Select(a => new AdminAlertItemDto
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
DeviceId = a.DeviceId,
|
||||||
|
DeviceName = a.Device.DeviceName,
|
||||||
|
OwnerName = a.Device.User.Name + " " + a.Device.User.Family,
|
||||||
|
Message = a.Message,
|
||||||
|
NotificationType = a.NotificationType,
|
||||||
|
Status = a.Status,
|
||||||
|
ErrorMessage = a.ErrorMessage,
|
||||||
|
SentAt = a.SentAt
|
||||||
|
}).ToList(),
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = filter.Page,
|
||||||
|
PageSize = filter.PageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Posts ====================
|
||||||
|
|
||||||
|
public async Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (filter.Page <= 0) filter.Page = 1;
|
||||||
|
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||||||
|
|
||||||
|
IQueryable<Domain.DevicePost> query = _dbContext.DevicePosts.AsNoTracking()
|
||||||
|
.Include(p => p.Device)
|
||||||
|
.Include(p => p.AuthorUser)
|
||||||
|
.Include(p => p.Images);
|
||||||
|
|
||||||
|
if (filter.DeviceId.HasValue)
|
||||||
|
query = query.Where(p => p.DeviceId == filter.DeviceId.Value);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
|
.ThenByDescending(p => p.Id)
|
||||||
|
.Skip((filter.Page - 1) * filter.PageSize)
|
||||||
|
.Take(filter.PageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new PagedResult<AdminPostItemDto>
|
||||||
|
{
|
||||||
|
// ponytail: like/reply count intentionally omitted — DevicePost has no such fields yet.
|
||||||
|
Items = items.Select(p => new AdminPostItemDto
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
DeviceId = p.DeviceId,
|
||||||
|
DeviceName = p.Device.DeviceName,
|
||||||
|
AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family,
|
||||||
|
Content = p.Content,
|
||||||
|
PostType = p.PostType,
|
||||||
|
ImageCount = p.Images.Count,
|
||||||
|
CreatedAt = p.CreatedAt
|
||||||
|
}).ToList(),
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = filter.Page,
|
||||||
|
PageSize = filter.PageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _dbContext.DevicePosts
|
||||||
|
.Include(p => p.Images)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||||
|
if (post == null) return false;
|
||||||
|
|
||||||
|
// remove image files from disc (best effort — a missing file must not block the delete)
|
||||||
|
var webRoot = _environment.WebRootPath ?? "wwwroot";
|
||||||
|
foreach (var image in post.Images)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(webRoot, image.FilePath.TrimStart('/'));
|
||||||
|
if (File.Exists(fullPath)) File.Delete(fullPath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// file cleanup is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(post.VoiceFilePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var voicePath = Path.Combine(webRoot, post.VoiceFilePath.TrimStart('/'));
|
||||||
|
if (File.Exists(voicePath)) File.Delete(voicePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// file cleanup is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddAuditLog(currentAdminId, "Post.Delete", id.ToString());
|
||||||
|
_dbContext.DevicePosts.Remove(post);
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Device Debug ====================
|
||||||
|
|
||||||
|
public async Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var device = await _dbContext.Devices.AsNoTracking()
|
||||||
|
.Include(d => d.User)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||||
|
if (device == null)
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
|
||||||
|
|
||||||
|
var mobile = MobileNormalizer.Normalize(device.User.Mobile);
|
||||||
|
if (mobile.Length == 0)
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" };
|
||||||
|
|
||||||
|
// persist the audit row up-front so it is recorded no matter how the send attempt ends
|
||||||
|
AddAuditLog(currentAdminId, "Device.TestSms", deviceId.ToString(),
|
||||||
|
JsonSerializer.Serialize(new { mobile }));
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _smsService.SendWebserviceSmsAsync(new WebserviceSmsRequest
|
||||||
|
{
|
||||||
|
Recipient = mobile,
|
||||||
|
Message = $"پیامک تست GreenHome برای دستگاه {device.DeviceName} — {DateTime.UtcNow:yyyy/MM/dd HH:mm} UTC"
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result?.Meta.Status == true)
|
||||||
|
{
|
||||||
|
await _usageTracker.RecordAsync(device.User.Id, UsageKind.Sms, cancellationToken: cancellationToken);
|
||||||
|
return new AdminDeviceTestResultDto { Success = true, Message = $"پیامک تست برای {mobile} ارسال شد" };
|
||||||
|
}
|
||||||
|
|
||||||
|
var reason = result?.Meta.Message ?? "خطای نامشخص سرویس پیامک";
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = reason };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در ارسال: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var device = await _dbContext.Devices.AsNoTracking()
|
||||||
|
.Include(d => d.User)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||||
|
if (device == null)
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
|
||||||
|
|
||||||
|
var mobile = MobileNormalizer.Normalize(device.User.Mobile);
|
||||||
|
if (mobile.Length == 0)
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = "شماره موبایل مالک معتبر نیست" };
|
||||||
|
|
||||||
|
// persist the audit row up-front so it is recorded no matter how the send attempt ends
|
||||||
|
AddAuditLog(currentAdminId, "Device.TestVoice", deviceId.ToString(),
|
||||||
|
JsonSerializer.Serialize(new { mobile }));
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _voiceCallService.QuickSendWithTTSAsync(new QuickSendWithTTSRequest
|
||||||
|
{
|
||||||
|
Text = $"پیام صوتی تست از پنل مدیریت گریهوم برای دستگاه {device.DeviceName}",
|
||||||
|
Number = mobile,
|
||||||
|
ServerID = 0
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
if (result?.IsSuccess == true)
|
||||||
|
{
|
||||||
|
// ponytail: ثبت مصرف فقط وقتی call واقعی ارسال میشود (هماهنگ با AlertNotificationSender)
|
||||||
|
await _usageTracker.RecordAsync(device.User.Id, UsageKind.VoiceCall, cancellationToken: cancellationToken);
|
||||||
|
return new AdminDeviceTestResultDto { Success = true, Message = $"تماس صوتی تست برای {mobile} انجام شد" };
|
||||||
|
}
|
||||||
|
|
||||||
|
var reason = result == null ? "پاسخی از سرویس صوتی دریافت نشد"
|
||||||
|
: $"کد خطا: {result.ReturnValue}";
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = reason };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new AdminDeviceTestResultDto { Success = false, Message = $"خطا در تماس صوتی: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
|
public async Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (filter.Page <= 0) filter.Page = 1;
|
||||||
|
if (filter.PageSize <= 0) filter.PageSize = 20;
|
||||||
|
|
||||||
|
IQueryable<Domain.AdminAuditLog> query = _dbContext.AdminAuditLogs.AsNoTracking()
|
||||||
|
.Include(a => a.AdminUser);
|
||||||
|
|
||||||
|
if (filter.AdminId.HasValue)
|
||||||
|
query = query.Where(a => a.AdminUserId == filter.AdminId.Value);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(a => a.At)
|
||||||
|
.ThenByDescending(a => a.Id)
|
||||||
|
.Skip((filter.Page - 1) * filter.PageSize)
|
||||||
|
.Take(filter.PageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new PagedResult<AdminAuditLogDto>
|
||||||
|
{
|
||||||
|
Items = items.Select(a => new AdminAuditLogDto
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
AdminUserId = a.AdminUserId,
|
||||||
|
AdminName = a.AdminUser.Name + " " + a.AdminUser.Family,
|
||||||
|
Action = a.Action,
|
||||||
|
TargetId = a.TargetId,
|
||||||
|
Details = a.Details,
|
||||||
|
At = a.At
|
||||||
|
}).ToList(),
|
||||||
|
TotalCount = totalCount,
|
||||||
|
Page = filter.Page,
|
||||||
|
PageSize = filter.PageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Helpers ====================
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک ردیف audit log به change tracker اضافه میکند؛ با همان SaveChanges عملیات اصلی ذخیره میشود.
|
||||||
|
/// </summary>
|
||||||
|
private void AddAuditLog(int adminUserId, string action, string targetId, string details = "")
|
||||||
|
{
|
||||||
|
_dbContext.AdminAuditLogs.Add(new Domain.AdminAuditLog
|
||||||
|
{
|
||||||
|
AdminUserId = adminUserId,
|
||||||
|
Action = action,
|
||||||
|
TargetId = targetId,
|
||||||
|
Details = details,
|
||||||
|
At = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیستهای admin).
|
/// آخرین تلیمتری هر دستگاه + نام مالک (برای لیستهای admin).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import AdminLayout from '@/app/admin/AdminLayout'
|
|||||||
import AdminDashboardPage from '@/app/admin/page'
|
import AdminDashboardPage from '@/app/admin/page'
|
||||||
import AdminUsersPage from '@/app/admin/users/page'
|
import AdminUsersPage from '@/app/admin/users/page'
|
||||||
import AdminDevicesPage from '@/app/admin/devices/page'
|
import AdminDevicesPage from '@/app/admin/devices/page'
|
||||||
|
import AdminAlertsPage from '@/app/admin/alerts/page'
|
||||||
|
import AdminPostsPage from '@/app/admin/posts/page'
|
||||||
import AdminUsagePage from '@/app/admin/usage/page'
|
import AdminUsagePage from '@/app/admin/usage/page'
|
||||||
|
import AdminAuditPage from '@/app/admin/audit/page'
|
||||||
|
|
||||||
function DeviceRedirect() {
|
function DeviceRedirect() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -46,7 +49,10 @@ export default function App() {
|
|||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="devices" element={<AdminDevicesPage />} />
|
<Route path="devices" element={<AdminDevicesPage />} />
|
||||||
|
<Route path="alerts" element={<AdminAlertsPage />} />
|
||||||
|
<Route path="posts" element={<AdminPostsPage />} />
|
||||||
<Route path="usage" element={<AdminUsagePage />} />
|
<Route path="usage" element={<AdminUsagePage />} />
|
||||||
|
<Route path="audit" element={<AdminAuditPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
import { Outlet, useLocation } from 'react-router-dom'
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
import { Link } from '@/lib/router'
|
import { Link } from '@/lib/router'
|
||||||
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight } from 'lucide-react'
|
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
|
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
|
||||||
{ href: '/admin/users', label: 'کاربران', icon: Users, exact: false },
|
{ href: '/admin/users', label: 'کاربران', icon: Users, exact: false },
|
||||||
{ href: '/admin/devices', label: 'دستگاهها', icon: Cpu, exact: false },
|
{ href: '/admin/devices', label: 'دستگاهها', icon: Cpu, exact: false },
|
||||||
|
{ href: '/admin/alerts', label: 'اعلانها', icon: Bell, exact: false },
|
||||||
|
{ href: '/admin/posts', label: 'پستها', icon: MessageSquare, exact: false },
|
||||||
{ href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false },
|
{ href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false },
|
||||||
|
{ href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
|
|||||||
215
src/GreenHome.Web/src/app/admin/alerts/page.tsx
Normal file
215
src/GreenHome.Web/src/app/admin/alerts/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
|
||||||
|
import { Bell, MessageSquare, Phone, AlertTriangle } from 'lucide-react'
|
||||||
|
import { api, PagedResult, AdminAlertItem } from '@/lib/api'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||||
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
|
import { Badge } from '@/components/common/Badge'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
// AlertStatus: Success=1, Failed=2, Pending=3
|
||||||
|
const STATUS_META: Record<number, { label: string; variant: 'success' | 'error' | 'warning' }> = {
|
||||||
|
1: { label: 'موفق', variant: 'success' },
|
||||||
|
2: { label: 'ناموفق', variant: 'error' },
|
||||||
|
3: { label: 'در صف', variant: 'warning' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertNotificationType: Call=-1, SMS=0, Relay1..4=1..4
|
||||||
|
function notificationLabel(type: number): { label: string; icon: typeof MessageSquare } {
|
||||||
|
if (type === -1) return { label: 'تماس صوتی', icon: Phone }
|
||||||
|
if (type === 0) return { label: 'پیامک', icon: MessageSquare }
|
||||||
|
if (type >= 1 && type <= 4) return { label: `رله ${toPersianDigits(type)}`, icon: Bell }
|
||||||
|
return { label: 'نامشخص', icon: Bell }
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgoIso(n: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - n)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminAlertsPage() {
|
||||||
|
const [result, setResult] = useState<PagedResult<AdminAlertItem> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [onlyErrors, setOnlyErrors] = useState(false)
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [deviceId, setDeviceId] = useState('')
|
||||||
|
|
||||||
|
const fetchAlerts = useCallback(async (p: number, errorsOnly: boolean, f: string, t: string, devId: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.adminAlerts({
|
||||||
|
status: errorsOnly ? 2 : undefined,
|
||||||
|
from: f || undefined,
|
||||||
|
to: t ? `${t}T23:59:59` : undefined,
|
||||||
|
deviceId: devId ? Number(devId) : undefined,
|
||||||
|
page: p,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
setResult(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در دریافت هشدارها')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAlerts(page, onlyErrors, from, to, deviceId)
|
||||||
|
}, [page, onlyErrors, from, to, deviceId, fetchAlerts])
|
||||||
|
|
||||||
|
const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyLast7Days = () => {
|
||||||
|
setFrom(daysAgoIso(6))
|
||||||
|
setTo(new Date().toISOString().slice(0, 10))
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFrom('')
|
||||||
|
setTo('')
|
||||||
|
setDeviceId('')
|
||||||
|
setOnlyErrors(false)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 flex-wrap">
|
||||||
|
<h2 className="text-base font-bold text-gray-900">فید هشدارها</h2>
|
||||||
|
{result && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{toPersianDigits(result.totalCount)} هشدار
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant={onlyErrors ? 'danger' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
onClick={() => { setOnlyErrors(!onlyErrors); setPage(1) }}
|
||||||
|
>
|
||||||
|
فقط خطاها
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" size="sm" onClick={applyLast7Days}>
|
||||||
|
{toPersianDigits(7)} روز اخیر
|
||||||
|
</Button>
|
||||||
|
{(from || to || deviceId || onlyErrors) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
حذف فیلترها
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mr-auto">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => { setDeviceId(e.target.value); setPage(1) }}
|
||||||
|
placeholder="شناسه دستگاه"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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-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((alert) => {
|
||||||
|
const status = STATUS_META[alert.status] ?? STATUS_META[3]
|
||||||
|
const notif = notificationLabel(alert.notificationType)
|
||||||
|
const NotifIcon = notif.icon
|
||||||
|
return (
|
||||||
|
<tr key={alert.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 whitespace-nowrap">
|
||||||
|
{alert.deviceName}
|
||||||
|
<span className="text-xs text-gray-400 font-normal"> ({toPersianDigits(alert.deviceId)})</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 whitespace-nowrap">{alert.ownerName}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge variant="default" size="sm" icon={NotifIcon}>{notif.label}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 max-w-xs">
|
||||||
|
<p className="truncate" title={alert.message}>{alert.message}</p>
|
||||||
|
{alert.status === 2 && alert.errorMessage && (
|
||||||
|
<p className="text-xs text-red-500 truncate" title={alert.errorMessage}>{alert.errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge variant={status.variant} size="sm">{status.label}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{formatPersianDateFromIso(alert.sentAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{showPagination && (
|
||||||
|
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Bell}
|
||||||
|
title="هشدار یافت نشد"
|
||||||
|
message="برای بازه و فیلتر انتخابی، هشداری ثبت نشده است."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/GreenHome.Web/src/app/admin/audit/page.tsx
Normal file
110
src/GreenHome.Web/src/app/admin/audit/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ScrollText } from 'lucide-react'
|
||||||
|
import { api, PagedResult, AdminAuditLogItem } from '@/lib/api'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||||
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
|
import { Badge } from '@/components/common/Badge'
|
||||||
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export default function AdminAuditPage() {
|
||||||
|
const [result, setResult] = useState<PagedResult<AdminAuditLogItem> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async (p: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.adminAuditLogs({ page: p, pageSize: PAGE_SIZE })
|
||||||
|
setResult(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در دریافت لاگ مدیریت')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs(page)
|
||||||
|
}, [page, fetchLogs])
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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-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((log) => (
|
||||||
|
<tr key={log.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="py-3 px-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{formatPersianDateFromIso(log.at)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 whitespace-nowrap">{log.adminName}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge variant="info" size="sm">
|
||||||
|
<span dir="ltr">{log.action}</span>
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 tabular-nums whitespace-nowrap">
|
||||||
|
{toPersianDigits(log.targetId)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-400 text-xs max-w-xs">
|
||||||
|
<span className="break-all" dir="ltr" title={log.details}>{log.details || '—'}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{showPagination && (
|
||||||
|
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={ScrollText}
|
||||||
|
title="عملیاتی ثبت نشده"
|
||||||
|
message="هنوز هیچ عملیات مدیریتی در لاگ ثبت نشده است."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Cpu, Thermometer, Eye } from 'lucide-react'
|
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall } from 'lucide-react'
|
||||||
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail } from '@/lib/api'
|
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult } from '@/lib/api'
|
||||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||||
import { SearchInput } from '@/components/forms/SearchInput'
|
import { SearchInput } from '@/components/forms/SearchInput'
|
||||||
@@ -22,6 +22,8 @@ export default function AdminDevicesPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [detail, setDetail] = useState<AdminDeviceDetail | null>(null)
|
const [detail, setDetail] = useState<AdminDeviceDetail | null>(null)
|
||||||
|
const [testSms, setTestSms] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||||
|
const [testVoice, setTestVoice] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||||
|
|
||||||
const fetchDevices = useCallback(async (p: number, s: string) => {
|
const fetchDevices = useCallback(async (p: number, s: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -49,12 +51,26 @@ export default function AdminDevicesPage() {
|
|||||||
|
|
||||||
const openDetail = async (id: number) => {
|
const openDetail = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
setDetail(await api.adminDevice(id))
|
const d = await api.adminDevice(id)
|
||||||
|
setDetail(d)
|
||||||
|
setTestSms({ busy: false, result: null })
|
||||||
|
setTestVoice({ busy: false, result: null })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه')
|
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runTest = async (kind: 'sms' | 'voice', deviceId: number, setter: typeof setTestSms) => {
|
||||||
|
setter({ busy: true, result: null })
|
||||||
|
try {
|
||||||
|
const fn = kind === 'sms' ? api.adminTestSms : api.adminTestVoice
|
||||||
|
const res = await fn(deviceId)
|
||||||
|
setter({ busy: false, result: res })
|
||||||
|
} catch (e) {
|
||||||
|
setter({ busy: false, result: { success: false, message: (e as Error).message || 'خطای ارتباطی' } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
||||||
const showPagination = totalPages > 1
|
const showPagination = totalPages > 1
|
||||||
|
|
||||||
@@ -191,6 +207,36 @@ export default function AdminDevicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">تست اعلان</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" icon={MessageSquare}
|
||||||
|
disabled={testSms.busy || testVoice.busy}
|
||||||
|
onClick={() => runTest('sms', detail.id, setTestSms)}>
|
||||||
|
{testSms.busy ? 'در حال ارسال...' : 'تست پیامک'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" icon={PhoneCall}
|
||||||
|
disabled={testSms.busy || testVoice.busy}
|
||||||
|
onClick={() => runTest('voice', detail.id, setTestVoice)}>
|
||||||
|
{testVoice.busy ? 'در حال تماس...' : 'تست تماس صوتی'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(testSms.result || testVoice.result) && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{testSms.result && (
|
||||||
|
<p className={testSms.result.success ? 'text-xs text-green-700 bg-green-50 rounded px-2 py-1' : 'text-xs text-red-700 bg-red-50 rounded px-2 py-1'}>
|
||||||
|
{testSms.result.success ? '✓ ' : '✗ '}پیامک: {testSms.result.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{testVoice.result && (
|
||||||
|
<p className={testVoice.result.success ? 'text-xs text-green-700 bg-green-50 rounded px-2 py-1' : 'text-xs text-red-700 bg-red-50 rounded px-2 py-1'}>
|
||||||
|
{testVoice.result.success ? '✓ ' : '✗ '}تماس صوتی: {testVoice.result.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||||
<Thermometer className="w-4 h-4 text-gray-400" />
|
<Thermometer className="w-4 h-4 text-gray-400" />
|
||||||
|
|||||||
165
src/GreenHome.Web/src/app/admin/posts/page.tsx
Normal file
165
src/GreenHome.Web/src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2 } from 'lucide-react'
|
||||||
|
import { api, PagedResult, AdminPostItem } from '@/lib/api'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||||
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
|
import { Badge } from '@/components/common/Badge'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
import { useConfirmDialog } from '@/components/utils/ConfirmDialog'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
// DevicePostType: Text=0, Image=1, Voice=2, SensorSnapshot=3
|
||||||
|
const POST_TYPE_META: Record<number, { label: string; icon: typeof MessageSquare }> = {
|
||||||
|
0: { label: 'متن', icon: MessageSquare },
|
||||||
|
1: { label: 'تصویر', icon: ImageIcon },
|
||||||
|
2: { label: 'ویس', icon: Mic },
|
||||||
|
3: { label: 'اسنپشات', icon: Camera },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPostsPage() {
|
||||||
|
const [result, setResult] = useState<PagedResult<AdminPostItem> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null)
|
||||||
|
const { confirm, ConfirmDialog } = useConfirmDialog()
|
||||||
|
|
||||||
|
const fetchPosts = useCallback(async (p: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.adminPosts({ page: p, pageSize: PAGE_SIZE })
|
||||||
|
setResult(data)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در دریافت پستها')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPosts(page)
|
||||||
|
}, [page, fetchPosts])
|
||||||
|
|
||||||
|
const handleDelete = async (post: AdminPostItem) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'حذف پست',
|
||||||
|
message: `این پست بهطور کامل حذف میشود (با تصاویر آن). ادامه میدهید؟`,
|
||||||
|
confirmText: 'حذف',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setBusyId(post.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.adminDeletePost(post.id)
|
||||||
|
// if the current page becomes empty, step back a page
|
||||||
|
if (result && result.items.length === 1 && page > 1) {
|
||||||
|
setPage(page - 1)
|
||||||
|
} else {
|
||||||
|
fetchPosts(page)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در حذف پست')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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-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((post) => {
|
||||||
|
const meta = POST_TYPE_META[post.postType] ?? POST_TYPE_META[0]
|
||||||
|
const TypeIcon = meta.icon
|
||||||
|
return (
|
||||||
|
<tr key={post.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 whitespace-nowrap">
|
||||||
|
{post.deviceName}
|
||||||
|
<span className="text-xs text-gray-400 font-normal"> ({toPersianDigits(post.deviceId)})</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 whitespace-nowrap">{post.authorName}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Badge variant="default" size="sm" icon={TypeIcon}>{meta.label}</Badge>
|
||||||
|
{post.imageCount > 0 && (
|
||||||
|
<span className="text-xs text-gray-400 mr-1">
|
||||||
|
{toPersianDigits(post.imageCount)} تصویر
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700 max-w-sm">
|
||||||
|
<p className="truncate" title={post.content}>{post.content || '—'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{formatPersianDateFromIso(post.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<Button variant="ghost" size="sm" icon={Trash2} disabled={busyId === post.id}
|
||||||
|
onClick={() => handleDelete(post)} tooltip="حذف پست">
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{showPagination && (
|
||||||
|
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={MessageSquare}
|
||||||
|
title="پستی یافت نشد"
|
||||||
|
message="هنوز پستی ثبت نشده است."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ type ConfirmDialogOptions = {
|
|||||||
|
|
||||||
type ConfirmDialogHook = {
|
type ConfirmDialogHook = {
|
||||||
confirm: (options: ConfirmDialogOptions) => Promise<boolean>
|
confirm: (options: ConfirmDialogOptions) => Promise<boolean>
|
||||||
|
ConfirmDialog: React.ComponentType
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConfirmDialog(): ConfirmDialogHook {
|
export function useConfirmDialog(): ConfirmDialogHook {
|
||||||
@@ -71,7 +72,7 @@ export function useConfirmDialog(): ConfirmDialogHook {
|
|||||||
return {
|
return {
|
||||||
confirm,
|
confirm,
|
||||||
ConfirmDialog: ConfirmDialogComponent
|
ConfirmDialog: ConfirmDialogComponent
|
||||||
} as ConfirmDialogHook & { ConfirmDialog: React.ComponentType }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple confirm function for direct use (fallback to window.confirm)
|
// Simple confirm function for direct use (fallback to window.confirm)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import type {
|
|||||||
AdminDeviceListItem,
|
AdminDeviceListItem,
|
||||||
AdminDeviceDetail,
|
AdminDeviceDetail,
|
||||||
AdminUsageDay,
|
AdminUsageDay,
|
||||||
|
AdminAlertItem,
|
||||||
|
AdminPostItem,
|
||||||
|
AdminDeviceTestResult,
|
||||||
|
AdminAuditLogItem,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||||
@@ -239,4 +243,34 @@ export const api = {
|
|||||||
if (q.userId) params.set('userId', String(q.userId))
|
if (q.userId) params.set('userId', String(q.userId))
|
||||||
return http<AdminUsageDay[]>(`${API_BASE}/api/admin/usage?${params.toString()}`)
|
return http<AdminUsageDay[]>(`${API_BASE}/api/admin/usage?${params.toString()}`)
|
||||||
},
|
},
|
||||||
|
adminAlerts: (q: { deviceId?: number; status?: number; from?: string; to?: string; page?: number; pageSize?: number }) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
||||||
|
if (q.status) params.set('status', String(q.status))
|
||||||
|
if (q.from) params.set('from', q.from)
|
||||||
|
if (q.to) params.set('to', q.to)
|
||||||
|
if (q.page) params.set('page', String(q.page))
|
||||||
|
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||||
|
return http<PagedResult<AdminAlertItem>>(`${API_BASE}/api/admin/alerts?${params.toString()}`)
|
||||||
|
},
|
||||||
|
adminPosts: (q: { deviceId?: number; page?: number; pageSize?: number }) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
||||||
|
if (q.page) params.set('page', String(q.page))
|
||||||
|
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||||
|
return http<PagedResult<AdminPostItem>>(`${API_BASE}/api/admin/posts?${params.toString()}`)
|
||||||
|
},
|
||||||
|
adminDeletePost: (id: number) =>
|
||||||
|
http<void>(`${API_BASE}/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
|
adminTestSms: (deviceId: number) =>
|
||||||
|
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-sms`, { method: 'POST' }),
|
||||||
|
adminTestVoice: (deviceId: number) =>
|
||||||
|
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-voice`, { method: 'POST' }),
|
||||||
|
adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (q.adminId) params.set('adminId', String(q.adminId))
|
||||||
|
if (q.page) params.set('page', String(q.page))
|
||||||
|
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||||
|
return http<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,3 +342,41 @@ export type AdminUsageDay = {
|
|||||||
aiTokenCount: number
|
aiTokenCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminAlertItem = {
|
||||||
|
id: number
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
ownerName: string
|
||||||
|
message: string
|
||||||
|
notificationType: number
|
||||||
|
status: number
|
||||||
|
errorMessage?: string
|
||||||
|
sentAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPostItem = {
|
||||||
|
id: number
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
authorName: string
|
||||||
|
content: string
|
||||||
|
postType: number
|
||||||
|
imageCount: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminDeviceTestResult = {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminAuditLogItem = {
|
||||||
|
id: number
|
||||||
|
adminUserId: number
|
||||||
|
adminName: string
|
||||||
|
action: string
|
||||||
|
targetId: string
|
||||||
|
details: string
|
||||||
|
at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user