✨ feat(admin): add admin device, post, and report features
- add admin endpoints to create devices and update device settings - expose device members and post detail for admin management - add post filters by device, author, type, and date range - add user growth, device uptime, and usage reports with CSV export - improve admin layout with sticky header, user info, and logout - add device and author search filters across admin pages
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using GreenHome.Api.Filters;
|
using GreenHome.Api.Filters;
|
||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using GreenHome.Application.Dtos.Admin;
|
using GreenHome.Application.Dtos.Admin;
|
||||||
@@ -111,6 +112,44 @@ public sealed class AdminController : ControllerBase
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("devices")]
|
||||||
|
public async Task<IActionResult> CreateDevice([FromBody] AdminDeviceCreateRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.CreateDeviceAsync(request, currentAdminId, cancellationToken);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
AdminDeviceCreateOutcome.Ok => Ok(new { id = result.DeviceId, deviceName = result.DeviceName }),
|
||||||
|
AdminDeviceCreateOutcome.UserNotFound => NotFound(new { message = "کاربر مورد نظر یافت نشد" }),
|
||||||
|
AdminDeviceCreateOutcome.ConflictDuplicateName => Conflict(new { message = result.Message }),
|
||||||
|
_ => BadRequest(new { message = result.Message })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("devices/{id:int}/settings")]
|
||||||
|
public async Task<IActionResult> UpdateDeviceSettings(int id, [FromBody] AdminDeviceSettingsUpdateRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.UpdateDeviceSettingsAsync(id, request, currentAdminId, cancellationToken);
|
||||||
|
if (result.NotFound) return NotFound(new { message = "دستگاه یافت نشد" });
|
||||||
|
if (result.Message is not null) return BadRequest(new { message = result.Message });
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("devices/{id:int}/settings")]
|
||||||
|
public async Task<ActionResult<AdminDeviceDetailDto>> GetDeviceSettings(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetDeviceAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound(new { message = "دستگاه یافت نشد" }) : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("devices/{id:int}/members")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<AdminDeviceMemberDto>>> GetDeviceMembers(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetDeviceMembersAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound(new { message = "دستگاه یافت نشد" }) : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Usage ====================
|
// ==================== Usage ====================
|
||||||
|
|
||||||
[HttpGet("usage")]
|
[HttpGet("usage")]
|
||||||
@@ -155,6 +194,10 @@ public sealed class AdminController : ControllerBase
|
|||||||
[HttpGet("posts")]
|
[HttpGet("posts")]
|
||||||
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
||||||
[FromQuery] int? deviceId,
|
[FromQuery] int? deviceId,
|
||||||
|
[FromQuery] int? authorUserId,
|
||||||
|
[FromQuery] Domain.DevicePostType? postType,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -162,6 +205,10 @@ public sealed class AdminController : ControllerBase
|
|||||||
var filter = new AdminPostFilter
|
var filter = new AdminPostFilter
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
|
AuthorUserId = authorUserId,
|
||||||
|
PostType = postType,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
Page = Math.Max(1, page),
|
Page = Math.Max(1, page),
|
||||||
PageSize = Math.Clamp(pageSize, 1, 100)
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
};
|
};
|
||||||
@@ -170,6 +217,13 @@ public sealed class AdminController : ControllerBase
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("posts/{id:int}")]
|
||||||
|
public async Task<ActionResult<AdminPostDetailDto>> GetPost(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetPostAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound(new { message = "پست یافت نشد" }) : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("posts/{id:int}")]
|
[HttpDelete("posts/{id:int}")]
|
||||||
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -196,6 +250,73 @@ public sealed class AdminController : ControllerBase
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Reports ====================
|
||||||
|
|
||||||
|
[HttpGet("reports/users")]
|
||||||
|
public async Task<IActionResult> ReportUsers(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (f, t) = ResolveReportRange(from, to);
|
||||||
|
var rows = await _adminService.GetUserGrowthReportAsync(f, t, cancellationToken);
|
||||||
|
if (csv)
|
||||||
|
return ToCsvFile("report-users",
|
||||||
|
new[] { "تاریخ", "کاربران جدید", "کاربران فعال" },
|
||||||
|
rows.Select(r => new[] { r.Date.ToString("yyyy-MM-dd"), r.NewUsers.ToString(), r.ActiveUsers.ToString() }), t);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("reports/devices-uptime")]
|
||||||
|
public async Task<IActionResult> ReportDeviceUptime(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (f, t) = ResolveReportRange(from, to);
|
||||||
|
var rows = await _adminService.GetDeviceUptimeReportAsync(f, t, cancellationToken);
|
||||||
|
if (csv)
|
||||||
|
return ToCsvFile("report-devices-uptime",
|
||||||
|
new[] { "دستگاه", "مالک", "کل روزها", "روزهای آنلاین", "درصد آنلاین" },
|
||||||
|
rows.Select(r => new[] { r.DeviceName, r.OwnerName, r.TotalDays.ToString(), r.OnlineDays.ToString(), r.OnlinePercent.ToString() }), t);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("reports/usage-by-user")]
|
||||||
|
public async Task<IActionResult> ReportUsageByUser(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (f, t) = ResolveReportRange(from, to);
|
||||||
|
var rows = await _adminService.GetUsageByUserReportAsync(f, t, cancellationToken);
|
||||||
|
if (csv)
|
||||||
|
return ToCsvFile("report-usage-by-user",
|
||||||
|
new[] { "نام", "نام خانوادگی", "موبایل", "پیامک", "تماس صوتی", "درخواست هوش مصنوعی", "توکن هوش مصنوعی" },
|
||||||
|
rows.Select(r => new[] { r.Name, r.Family, r.Mobile, r.Sms.ToString(), r.VoiceCall.ToString(), r.AiQueries.ToString(), r.AiTokens.ToString() }), t);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DateTime From, DateTime To) ResolveReportRange(DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
var t = (to ?? DateTime.Today).Date;
|
||||||
|
var f = (from ?? t.AddDays(-6)).Date;
|
||||||
|
return (f, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: CSV via StringBuilder — fine at report size; swap for a streaming writer if reports grow.
|
||||||
|
private static FileContentResult ToCsvFile(string name, string[] headers, IEnumerable<string[]> rows, DateTime reportDate)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append('\uFEFF'); // UTF-8 BOM so Excel detects Persian correctly
|
||||||
|
sb.Append(string.Join(",", headers.Select(EscapeCsv))).Append('\n');
|
||||||
|
foreach (var row in rows)
|
||||||
|
sb.Append(string.Join(",", row.Select(EscapeCsv))).Append('\n');
|
||||||
|
var result = new FileContentResult(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv; charset=utf-8");
|
||||||
|
result.FileDownloadName = $"{name}-{reportDate:yyyy-MM-dd}.csv";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeCsv(string? value)
|
||||||
|
{
|
||||||
|
var v = value ?? string.Empty;
|
||||||
|
return v.Contains(',') || v.Contains('"') || v.Contains('\n') ? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Audit Log ====================
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
[HttpGet("audit-logs")]
|
[HttpGet("audit-logs")]
|
||||||
|
|||||||
@@ -113,10 +113,86 @@ public sealed class AdminDeviceDetailDto
|
|||||||
public string? DevicePhoneNumber { get; set; }
|
public string? DevicePhoneNumber { get; set; }
|
||||||
public int? UploadIntervalMin { get; set; }
|
public int? UploadIntervalMin { get; set; }
|
||||||
public int? RelayCount { get; set; }
|
public int? RelayCount { get; set; }
|
||||||
|
public string? NeshanLocation { get; set; }
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public int? MinimumSmsIntervalMinutes { get; set; }
|
||||||
|
public int? MinimumCallIntervalMinutes { get; set; }
|
||||||
|
public decimal? AreaSquareMeters { get; set; }
|
||||||
|
public Domain.SimCardType? SimCardType { get; set; }
|
||||||
|
public string? SmsRecievers { get; set; }
|
||||||
|
|
||||||
public IReadOnlyList<TelemetryDto> LastTelemetry { get; set; } = [];
|
public IReadOnlyList<TelemetryDto> LastTelemetry { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Devices: create / settings / members ====================
|
||||||
|
|
||||||
|
public sealed class AdminDeviceCreateRequest
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Location { get; set; } = string.Empty;
|
||||||
|
public string? NeshanLocation { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AdminDeviceCreateOutcome
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
UserNotFound,
|
||||||
|
ConflictDuplicateName,
|
||||||
|
Validation
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceCreateResult
|
||||||
|
{
|
||||||
|
public AdminDeviceCreateOutcome Outcome { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
/// <summary>پیام فارسی برای خطاهای Validation</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceSettingsUpdateRequest
|
||||||
|
{
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Province { get; set; } = string.Empty;
|
||||||
|
public string City { get; set; } = string.Empty;
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public string ProductType { get; set; } = string.Empty;
|
||||||
|
public int MinimumSmsIntervalMinutes { get; set; }
|
||||||
|
public int MinimumCallIntervalMinutes { get; set; }
|
||||||
|
public decimal? AreaSquareMeters { get; set; }
|
||||||
|
public int UploadIntervalMin { get; set; }
|
||||||
|
public string DevicePhoneNumber { get; set; } = string.Empty;
|
||||||
|
public Domain.SimCardType? SimCardType { get; set; }
|
||||||
|
public int RelayCount { get; set; }
|
||||||
|
public string SmsRecievers { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Success = !NotFound && Message == null
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminSettingsUpdateResult
|
||||||
|
{
|
||||||
|
public bool NotFound { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceMemberDto
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public string MemberName { get; set; } = string.Empty;
|
||||||
|
public string MemberFamily { get; set; } = string.Empty;
|
||||||
|
public Domain.DeviceMemberRole Role { get; set; }
|
||||||
|
public bool ReceiveAlerts { get; set; }
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
|
public bool IsOwner { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Usage ====================
|
// ==================== Usage ====================
|
||||||
|
|
||||||
public sealed class UsageDayDto
|
public sealed class UsageDayDto
|
||||||
@@ -158,6 +234,10 @@ public sealed class AdminAlertItemDto
|
|||||||
public sealed class AdminPostFilter
|
public sealed class AdminPostFilter
|
||||||
{
|
{
|
||||||
public int? DeviceId { get; set; }
|
public int? DeviceId { get; set; }
|
||||||
|
public int? AuthorUserId { get; set; }
|
||||||
|
public Domain.DevicePostType? PostType { get; set; }
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 1;
|
||||||
public int PageSize { get; set; } = 20;
|
public int PageSize { get; set; } = 20;
|
||||||
}
|
}
|
||||||
@@ -167,13 +247,38 @@ public sealed class AdminPostItemDto
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int DeviceId { get; set; }
|
public int DeviceId { get; set; }
|
||||||
public string DeviceName { get; set; } = string.Empty;
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public int AuthorUserId { get; set; }
|
||||||
public string AuthorName { get; set; } = string.Empty;
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
public Domain.DevicePostType PostType { get; set; }
|
public Domain.DevicePostType PostType { get; set; }
|
||||||
public int ImageCount { get; set; }
|
public int ImageCount { get; set; }
|
||||||
|
public bool HasVoice { get; set; }
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AdminPostDetailDto
|
||||||
|
{
|
||||||
|
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 AuthorFamily { get; set; } = string.Empty;
|
||||||
|
public Domain.DevicePostType PostType { get; set; }
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public IReadOnlyList<AdminPostImageDto> Images { get; set; } = [];
|
||||||
|
public string? VoiceFilePath { get; set; }
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminPostImageDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Device Debug ====================
|
// ==================== Device Debug ====================
|
||||||
|
|
||||||
public sealed class AdminDeviceTestResultDto
|
public sealed class AdminDeviceTestResultDto
|
||||||
@@ -182,6 +287,37 @@ public sealed class AdminDeviceTestResultDto
|
|||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Reports ====================
|
||||||
|
|
||||||
|
public sealed class AdminUserGrowthRowDto
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public int NewUsers { get; set; }
|
||||||
|
public int ActiveUsers { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceUptimeRowDto
|
||||||
|
{
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string OwnerName { get; set; } = string.Empty;
|
||||||
|
public int TotalDays { get; set; }
|
||||||
|
public int OnlineDays { get; set; }
|
||||||
|
public int OnlinePercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminUsageByUserRowDto
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public int Sms { get; set; }
|
||||||
|
public int VoiceCall { get; set; }
|
||||||
|
public int AiQueries { get; set; }
|
||||||
|
public int AiTokens { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Audit Log ====================
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
public sealed class AdminAuditLogFilter
|
public sealed class AdminAuditLogFilter
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ public interface IAdminService
|
|||||||
Task<UserMutationOutcome> SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken);
|
Task<UserMutationOutcome> SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken);
|
||||||
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<AdminDeviceCreateResult> CreateDeviceAsync(AdminDeviceCreateRequest request, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<AdminSettingsUpdateResult> UpdateDeviceSettingsAsync(int deviceId, AdminDeviceSettingsUpdateRequest request, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<AdminDeviceMemberDto>?> GetDeviceMembersAsync(int deviceId, 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<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken);
|
||||||
Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken);
|
Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<AdminPostDetailDto?> GetPostAsync(int id, CancellationToken cancellationToken);
|
||||||
Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken);
|
Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken);
|
||||||
Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||||
Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<AdminUserGrowthRowDto>> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<AdminDeviceUptimeRowDto>> GetDeviceUptimeReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<AdminUsageByUserRowDto>> GetUsageByUserReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||||
Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken);
|
Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public sealed class AdminService : IAdminService
|
|||||||
private readonly IVoiceCallService _voiceCallService;
|
private readonly IVoiceCallService _voiceCallService;
|
||||||
private readonly UsageTracker _usageTracker;
|
private readonly UsageTracker _usageTracker;
|
||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly IDeviceService _deviceService;
|
||||||
|
private readonly IDeviceSettingsService _deviceSettingsService;
|
||||||
private readonly int _onlineWindowMinutes;
|
private readonly int _onlineWindowMinutes;
|
||||||
|
|
||||||
public AdminService(
|
public AdminService(
|
||||||
@@ -30,7 +32,9 @@ public sealed class AdminService : IAdminService
|
|||||||
ISmsService smsService,
|
ISmsService smsService,
|
||||||
IVoiceCallService voiceCallService,
|
IVoiceCallService voiceCallService,
|
||||||
UsageTracker usageTracker,
|
UsageTracker usageTracker,
|
||||||
IWebHostEnvironment environment)
|
IWebHostEnvironment environment,
|
||||||
|
IDeviceService deviceService,
|
||||||
|
IDeviceSettingsService deviceSettingsService)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -38,6 +42,8 @@ public sealed class AdminService : IAdminService
|
|||||||
_voiceCallService = voiceCallService;
|
_voiceCallService = voiceCallService;
|
||||||
_usageTracker = usageTracker;
|
_usageTracker = usageTracker;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
|
_deviceService = deviceService;
|
||||||
|
_deviceSettingsService = deviceSettingsService;
|
||||||
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
|
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +307,237 @@ public sealed class AdminService : IAdminService
|
|||||||
DevicePhoneNumber = settings?.DevicePhoneNumber,
|
DevicePhoneNumber = settings?.DevicePhoneNumber,
|
||||||
UploadIntervalMin = settings?.UploadIntervalMin,
|
UploadIntervalMin = settings?.UploadIntervalMin,
|
||||||
RelayCount = settings?.RelayCount,
|
RelayCount = settings?.RelayCount,
|
||||||
|
NeshanLocation = string.IsNullOrWhiteSpace(device.NeshanLocation) ? null : device.NeshanLocation,
|
||||||
|
Latitude = settings?.Latitude,
|
||||||
|
Longitude = settings?.Longitude,
|
||||||
|
MinimumSmsIntervalMinutes = settings?.MinimumSmsIntervalMinutes,
|
||||||
|
MinimumCallIntervalMinutes = settings?.MinimumCallIntervalMinutes,
|
||||||
|
AreaSquareMeters = settings?.AreaSquareMeters,
|
||||||
|
SimCardType = settings?.SimCardType,
|
||||||
|
SmsRecievers = settings?.SmsRecievers,
|
||||||
LastTelemetry = _mapper.Map<IReadOnlyList<TelemetryDto>>(lastTelemetry)
|
LastTelemetry = _mapper.Map<IReadOnlyList<TelemetryDto>>(lastTelemetry)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Devices: create / settings / members ====================
|
||||||
|
|
||||||
|
public async Task<AdminDeviceCreateResult> CreateDeviceAsync(AdminDeviceCreateRequest request, int currentAdminId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deviceName = request.DeviceName?.Trim() ?? string.Empty;
|
||||||
|
if (deviceName.Length == 0 || deviceName.Length > 10)
|
||||||
|
return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.Validation, Message = "نام دستگاه باید بین ۱ تا ۱۰ کاراکتر باشد" };
|
||||||
|
|
||||||
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.UserNotFound };
|
||||||
|
|
||||||
|
if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName, cancellationToken))
|
||||||
|
return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.ConflictDuplicateName, Message = $"دستگاهی با نام «{deviceName}» از قبل وجود دارد" };
|
||||||
|
|
||||||
|
var deviceId = await _deviceService.AddDeviceAsync(new DeviceDto
|
||||||
|
{
|
||||||
|
DeviceName = deviceName,
|
||||||
|
UserId = request.UserId,
|
||||||
|
Location = request.Location?.Trim() ?? string.Empty,
|
||||||
|
NeshanLocation = request.NeshanLocation?.Trim() ?? string.Empty
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
// default settings (mirrors the app's default device-settings shape)
|
||||||
|
await _deviceSettingsService.CreateAsync(new DeviceSettingsDto
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
DeviceName = deviceName,
|
||||||
|
MinimumSmsIntervalMinutes = 15,
|
||||||
|
MinimumCallIntervalMinutes = 60,
|
||||||
|
UploadIntervalMin = 5,
|
||||||
|
RelayCount = 4,
|
||||||
|
Relays = Enumerable.Range(1, 4).Select(i => new DeviceRelayDto
|
||||||
|
{
|
||||||
|
RelayIndex = i,
|
||||||
|
Name = string.Empty,
|
||||||
|
IsEnabled = true,
|
||||||
|
RelayKind = Domain.RelayKind.Generic,
|
||||||
|
RuleGroups = new List<DeviceRelayRuleGroupDto>()
|
||||||
|
}).ToList()
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
AddAuditLog(currentAdminId, "Device.Create", deviceId.ToString(),
|
||||||
|
JsonSerializer.Serialize(new { userId = request.UserId, deviceName }));
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new AdminDeviceCreateResult
|
||||||
|
{
|
||||||
|
Outcome = AdminDeviceCreateOutcome.Ok,
|
||||||
|
DeviceId = deviceId,
|
||||||
|
DeviceName = deviceName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AdminSettingsUpdateResult> UpdateDeviceSettingsAsync(int deviceId, AdminDeviceSettingsUpdateRequest request, int currentAdminId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var device = await _dbContext.Devices
|
||||||
|
.Include(d => d.User)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||||
|
if (device == null)
|
||||||
|
return new AdminSettingsUpdateResult { NotFound = true };
|
||||||
|
|
||||||
|
var deviceName = request.DeviceName?.Trim() ?? string.Empty;
|
||||||
|
if (deviceName.Length == 0 || deviceName.Length > 10)
|
||||||
|
return new AdminSettingsUpdateResult { Message = "نام دستگاه باید بین ۱ تا ۱۰ کاراکتر باشد" };
|
||||||
|
|
||||||
|
if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName && d.Id != deviceId, cancellationToken))
|
||||||
|
return new AdminSettingsUpdateResult { Message = $"دستگاهی با نام «{deviceName}» از قبل وجود دارد" };
|
||||||
|
|
||||||
|
if (request.MinimumSmsIntervalMinutes < 1 || request.MinimumCallIntervalMinutes < 1 || request.UploadIntervalMin < 1)
|
||||||
|
return new AdminSettingsUpdateResult { Message = "فاصلههای زمانی باید بزرگتر یا مساوی ۱ دقیقه باشند" };
|
||||||
|
|
||||||
|
if (request.RelayCount < 1 || request.RelayCount > 16)
|
||||||
|
return new AdminSettingsUpdateResult { Message = "تعداد رله باید بین ۱ تا ۱۶ باشد" };
|
||||||
|
|
||||||
|
// keep existing location on the device entity (not part of this form)
|
||||||
|
var settings = await _dbContext.DeviceSettings
|
||||||
|
.Include(s => s.Relays)
|
||||||
|
.ThenInclude(r => r.RuleGroups)
|
||||||
|
.ThenInclude(g => g.Conditions)
|
||||||
|
.FirstOrDefaultAsync(s => s.DeviceId == deviceId, cancellationToken);
|
||||||
|
|
||||||
|
var dto = new DeviceSettingsDto
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
DeviceName = deviceName,
|
||||||
|
Province = request.Province?.Trim() ?? string.Empty,
|
||||||
|
City = request.City?.Trim() ?? string.Empty,
|
||||||
|
Latitude = request.Latitude,
|
||||||
|
Longitude = request.Longitude,
|
||||||
|
ProductType = request.ProductType?.Trim() ?? string.Empty,
|
||||||
|
MinimumSmsIntervalMinutes = request.MinimumSmsIntervalMinutes,
|
||||||
|
MinimumCallIntervalMinutes = request.MinimumCallIntervalMinutes,
|
||||||
|
AreaSquareMeters = request.AreaSquareMeters,
|
||||||
|
UploadIntervalMin = request.UploadIntervalMin,
|
||||||
|
DevicePhoneNumber = request.DevicePhoneNumber?.Trim() ?? string.Empty,
|
||||||
|
SimCardType = request.SimCardType,
|
||||||
|
RelayCount = request.RelayCount,
|
||||||
|
SmsRecievers = request.SmsRecievers?.Trim() ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
if (settings == null)
|
||||||
|
{
|
||||||
|
dto.Id = 0;
|
||||||
|
dto.CreatedAt = DateTime.UtcNow;
|
||||||
|
dto.UpdatedAt = DateTime.UtcNow;
|
||||||
|
dto.Relays = Enumerable.Range(1, request.RelayCount).Select(i => new DeviceRelayDto
|
||||||
|
{
|
||||||
|
RelayIndex = i,
|
||||||
|
Name = string.Empty,
|
||||||
|
IsEnabled = true,
|
||||||
|
RelayKind = Domain.RelayKind.Generic,
|
||||||
|
RuleGroups = new List<DeviceRelayRuleGroupDto>()
|
||||||
|
}).ToList();
|
||||||
|
await _deviceSettingsService.CreateAsync(dto, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// keep existing relays so SyncRelays only adjusts count without wiping rules
|
||||||
|
dto.Id = settings.Id;
|
||||||
|
dto.Relays = settings.Relays
|
||||||
|
.OrderBy(r => r.RelayIndex)
|
||||||
|
.Select(r => new DeviceRelayDto
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
DeviceSettingsId = settings.Id,
|
||||||
|
RelayIndex = r.RelayIndex,
|
||||||
|
Name = r.Name,
|
||||||
|
IsEnabled = r.IsEnabled,
|
||||||
|
RelayKind = r.RelayKind,
|
||||||
|
RuleGroups = r.RuleGroups
|
||||||
|
.OrderBy(g => g.Order)
|
||||||
|
.Select(g => new DeviceRelayRuleGroupDto
|
||||||
|
{
|
||||||
|
Id = g.Id,
|
||||||
|
Action = g.Action,
|
||||||
|
Label = g.Label,
|
||||||
|
Order = g.Order,
|
||||||
|
Conditions = g.Conditions
|
||||||
|
.OrderBy(c => c.Order)
|
||||||
|
.Select(c => new DeviceRelayRuleConditionDto
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
ConditionType = c.ConditionType,
|
||||||
|
SensorType = c.SensorType,
|
||||||
|
ComparisonType = c.ComparisonType,
|
||||||
|
Value1 = c.Value1,
|
||||||
|
Value2 = c.Value2,
|
||||||
|
StartMinutes = c.StartMinutes,
|
||||||
|
EndMinutes = c.EndMinutes,
|
||||||
|
Order = c.Order
|
||||||
|
}).ToList()
|
||||||
|
}).ToList()
|
||||||
|
}).ToList();
|
||||||
|
await _deviceSettingsService.UpdateAsync(dto, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddAuditLog(currentAdminId, "Device.Update", deviceId.ToString(),
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
deviceName,
|
||||||
|
relayCount = request.RelayCount,
|
||||||
|
smsInterval = request.MinimumSmsIntervalMinutes,
|
||||||
|
callInterval = request.MinimumCallIntervalMinutes,
|
||||||
|
uploadInterval = request.UploadIntervalMin
|
||||||
|
}));
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new AdminSettingsUpdateResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AdminDeviceMemberDto>?> GetDeviceMembersAsync(int deviceId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var device = await _dbContext.Devices.AsNoTracking()
|
||||||
|
.Include(d => d.User)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||||
|
if (device == null) return null;
|
||||||
|
|
||||||
|
var members = await _dbContext.DeviceUsers.AsNoTracking()
|
||||||
|
.Include(du => du.User)
|
||||||
|
.Where(du => du.DeviceId == deviceId)
|
||||||
|
.OrderBy(du => du.Role)
|
||||||
|
.ThenBy(du => du.UserId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<AdminDeviceMemberDto>(members.Count + 1);
|
||||||
|
|
||||||
|
// owner row first (owner is NOT in DeviceUsers)
|
||||||
|
result.Add(new AdminDeviceMemberDto
|
||||||
|
{
|
||||||
|
UserId = device.User.Id,
|
||||||
|
Name = device.User.Name,
|
||||||
|
Family = device.User.Family,
|
||||||
|
Mobile = device.User.Mobile,
|
||||||
|
MemberName = device.User.Name,
|
||||||
|
MemberFamily = device.User.Family,
|
||||||
|
Role = Domain.DeviceMemberRole.Manager,
|
||||||
|
ReceiveAlerts = true,
|
||||||
|
AvatarPath = null,
|
||||||
|
IsOwner = true
|
||||||
|
});
|
||||||
|
|
||||||
|
result.AddRange(members.Select(du => new AdminDeviceMemberDto
|
||||||
|
{
|
||||||
|
UserId = du.UserId,
|
||||||
|
Name = du.User.Name,
|
||||||
|
Family = du.User.Family,
|
||||||
|
Mobile = du.User.Mobile,
|
||||||
|
MemberName = string.IsNullOrWhiteSpace(du.Name) ? du.User.Name : du.Name,
|
||||||
|
MemberFamily = string.IsNullOrWhiteSpace(du.Family) ? du.User.Family : du.Family,
|
||||||
|
Role = du.Role,
|
||||||
|
ReceiveAlerts = du.ReceiveAlerts,
|
||||||
|
AvatarPath = du.AvatarPath,
|
||||||
|
IsOwner = false
|
||||||
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Usage ====================
|
// ==================== Usage ====================
|
||||||
|
|
||||||
public async Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken)
|
||||||
@@ -404,6 +637,14 @@ public sealed class AdminService : IAdminService
|
|||||||
|
|
||||||
if (filter.DeviceId.HasValue)
|
if (filter.DeviceId.HasValue)
|
||||||
query = query.Where(p => p.DeviceId == filter.DeviceId.Value);
|
query = query.Where(p => p.DeviceId == filter.DeviceId.Value);
|
||||||
|
if (filter.AuthorUserId.HasValue)
|
||||||
|
query = query.Where(p => p.AuthorUserId == filter.AuthorUserId.Value);
|
||||||
|
if (filter.PostType.HasValue)
|
||||||
|
query = query.Where(p => p.PostType == filter.PostType.Value);
|
||||||
|
if (filter.From.HasValue)
|
||||||
|
query = query.Where(p => p.CreatedAt >= filter.From.Value);
|
||||||
|
if (filter.To.HasValue)
|
||||||
|
query = query.Where(p => p.CreatedAt <= filter.To.Value);
|
||||||
|
|
||||||
var totalCount = await query.CountAsync(cancellationToken);
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -422,10 +663,13 @@ public sealed class AdminService : IAdminService
|
|||||||
Id = p.Id,
|
Id = p.Id,
|
||||||
DeviceId = p.DeviceId,
|
DeviceId = p.DeviceId,
|
||||||
DeviceName = p.Device.DeviceName,
|
DeviceName = p.Device.DeviceName,
|
||||||
|
AuthorUserId = p.AuthorUserId,
|
||||||
AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family,
|
AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family,
|
||||||
Content = p.Content,
|
Content = p.Content,
|
||||||
PostType = p.PostType,
|
PostType = p.PostType,
|
||||||
ImageCount = p.Images.Count,
|
ImageCount = p.Images.Count,
|
||||||
|
HasVoice = !string.IsNullOrWhiteSpace(p.VoiceFilePath),
|
||||||
|
VoiceDurationSeconds = p.VoiceDurationSeconds,
|
||||||
CreatedAt = p.CreatedAt
|
CreatedAt = p.CreatedAt
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
TotalCount = totalCount,
|
TotalCount = totalCount,
|
||||||
@@ -434,6 +678,36 @@ public sealed class AdminService : IAdminService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AdminPostDetailDto?> GetPostAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var post = await _dbContext.DevicePosts.AsNoTracking()
|
||||||
|
.Include(p => p.Device)
|
||||||
|
.Include(p => p.AuthorUser)
|
||||||
|
.Include(p => p.Images)
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||||
|
if (post == null) return null;
|
||||||
|
|
||||||
|
return new AdminPostDetailDto
|
||||||
|
{
|
||||||
|
Id = post.Id,
|
||||||
|
DeviceId = post.DeviceId,
|
||||||
|
DeviceName = post.Device.DeviceName,
|
||||||
|
AuthorName = post.AuthorUser.Name,
|
||||||
|
AuthorFamily = post.AuthorUser.Family,
|
||||||
|
PostType = post.PostType,
|
||||||
|
Content = post.Content,
|
||||||
|
CreatedAt = post.CreatedAt,
|
||||||
|
Images = post.Images.Select(i => new AdminPostImageDto
|
||||||
|
{
|
||||||
|
Id = i.Id,
|
||||||
|
FileName = i.FileName,
|
||||||
|
FilePath = i.FilePath
|
||||||
|
}).ToList(),
|
||||||
|
VoiceFilePath = post.VoiceFilePath,
|
||||||
|
VoiceDurationSeconds = post.VoiceDurationSeconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken)
|
public async Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var post = await _dbContext.DevicePosts
|
var post = await _dbContext.DevicePosts
|
||||||
@@ -559,6 +833,120 @@ public sealed class AdminService : IAdminService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Reports ====================
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AdminUserGrowthRowDto>> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fromDate = DateOnly.FromDateTime(from.Date);
|
||||||
|
var toDate = DateOnly.FromDateTime(to.Date);
|
||||||
|
|
||||||
|
var newUsers = await _dbContext.Users.AsNoTracking()
|
||||||
|
.Where(u => DateOnly.FromDateTime(u.CreatedAt) >= fromDate && DateOnly.FromDateTime(u.CreatedAt) <= toDate)
|
||||||
|
.Select(u => new { Date = DateOnly.FromDateTime(u.CreatedAt) })
|
||||||
|
.GroupBy(x => x.Date)
|
||||||
|
.Select(g => new { Date = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Date, x => x.Count, cancellationToken);
|
||||||
|
|
||||||
|
var activeUsers = await _dbContext.Users.AsNoTracking()
|
||||||
|
.Where(u => u.LastLoginAt != null
|
||||||
|
&& DateOnly.FromDateTime(u.LastLoginAt!.Value) >= fromDate
|
||||||
|
&& DateOnly.FromDateTime(u.LastLoginAt!.Value) <= toDate)
|
||||||
|
.Select(u => new { Date = DateOnly.FromDateTime(u.LastLoginAt!.Value) })
|
||||||
|
.GroupBy(x => x.Date)
|
||||||
|
.Select(g => new { Date = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Date, x => x.Count, cancellationToken);
|
||||||
|
|
||||||
|
var rows = new List<AdminUserGrowthRowDto>();
|
||||||
|
for (var day = fromDate; day <= toDate; day = day.AddDays(1))
|
||||||
|
{
|
||||||
|
rows.Add(new AdminUserGrowthRowDto
|
||||||
|
{
|
||||||
|
Date = day,
|
||||||
|
NewUsers = newUsers.GetValueOrDefault(day),
|
||||||
|
ActiveUsers = activeUsers.GetValueOrDefault(day)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AdminDeviceUptimeRowDto>> GetDeviceUptimeReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// ponytail: per-day distinct scan of TelemetryRecords is O(total rows in window);
|
||||||
|
// fine for a few dozen devices — swap for a pre-aggregated daily table if it grows.
|
||||||
|
var fromDate = DateOnly.FromDateTime(from.Date);
|
||||||
|
var toDate = DateOnly.FromDateTime(to.Date);
|
||||||
|
var totalDays = toDate.DayNumber - fromDate.DayNumber + 1;
|
||||||
|
|
||||||
|
var devices = await _dbContext.Devices.AsNoTracking()
|
||||||
|
.Include(d => d.User)
|
||||||
|
.OrderBy(d => d.DeviceName)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var onlineDays = await _dbContext.TelemetryRecords.AsNoTracking()
|
||||||
|
.Where(t => t.ServerTimestampUtc >= from.Date && t.ServerTimestampUtc < to.Date.AddDays(1))
|
||||||
|
.GroupBy(t => t.DeviceId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
DeviceId = g.Key,
|
||||||
|
Days = g.Select(t => DateOnly.FromDateTime(t.ServerTimestampUtc)).Distinct().Count()
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(x => x.DeviceId, x => x.Days, cancellationToken);
|
||||||
|
|
||||||
|
return devices.Select(d =>
|
||||||
|
{
|
||||||
|
var online = onlineDays.GetValueOrDefault(d.Id, 0);
|
||||||
|
return new AdminDeviceUptimeRowDto
|
||||||
|
{
|
||||||
|
DeviceId = d.Id,
|
||||||
|
DeviceName = d.DeviceName,
|
||||||
|
OwnerName = d.User.Name + " " + d.User.Family,
|
||||||
|
TotalDays = totalDays,
|
||||||
|
OnlineDays = online,
|
||||||
|
OnlinePercent = totalDays > 0 ? (int)Math.Round(online * 100.0 / totalDays) : 0
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AdminUsageByUserRowDto>> GetUsageByUserReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fromDate = DateOnly.FromDateTime(from.Date);
|
||||||
|
var toDate = DateOnly.FromDateTime(to.Date);
|
||||||
|
|
||||||
|
var rows = await _dbContext.UsageCounters.AsNoTracking()
|
||||||
|
.Where(u => u.Date >= fromDate && u.Date <= toDate)
|
||||||
|
.GroupBy(u => u.UserId)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
UserId = 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)
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var userIds = rows.Select(r => r.UserId).ToList();
|
||||||
|
var users = await _dbContext.Users.AsNoTracking()
|
||||||
|
.Where(u => userIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Where(r => users.ContainsKey(r.UserId))
|
||||||
|
.OrderByDescending(r => r.Sms + r.Voice + r.AiQueries)
|
||||||
|
.Select(r => new AdminUsageByUserRowDto
|
||||||
|
{
|
||||||
|
UserId = r.UserId,
|
||||||
|
Name = users[r.UserId].Name,
|
||||||
|
Family = users[r.UserId].Family,
|
||||||
|
Mobile = users[r.UserId].Mobile,
|
||||||
|
Sms = r.Sms,
|
||||||
|
VoiceCall = r.Voice,
|
||||||
|
AiQueries = r.AiQueries,
|
||||||
|
AiTokens = r.AiTokens
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Audit Log ====================
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
public async Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken)
|
public async Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import AdminDevicesPage from '@/app/admin/devices/page'
|
|||||||
import AdminAlertsPage from '@/app/admin/alerts/page'
|
import AdminAlertsPage from '@/app/admin/alerts/page'
|
||||||
import AdminPostsPage from '@/app/admin/posts/page'
|
import AdminPostsPage from '@/app/admin/posts/page'
|
||||||
import AdminUsagePage from '@/app/admin/usage/page'
|
import AdminUsagePage from '@/app/admin/usage/page'
|
||||||
|
import AdminReportsPage from '@/app/admin/reports/page'
|
||||||
import AdminAuditPage from '@/app/admin/audit/page'
|
import AdminAuditPage from '@/app/admin/audit/page'
|
||||||
|
|
||||||
function DeviceRedirect() {
|
function DeviceRedirect() {
|
||||||
@@ -52,6 +53,7 @@ export default function App() {
|
|||||||
<Route path="alerts" element={<AdminAlertsPage />} />
|
<Route path="alerts" element={<AdminAlertsPage />} />
|
||||||
<Route path="posts" element={<AdminPostsPage />} />
|
<Route path="posts" element={<AdminPostsPage />} />
|
||||||
<Route path="usage" element={<AdminUsagePage />} />
|
<Route path="usage" element={<AdminUsagePage />} />
|
||||||
|
<Route path="reports" element={<AdminReportsPage />} />
|
||||||
<Route path="audit" element={<AdminAuditPage />} />
|
<Route path="audit" element={<AdminAuditPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { Outlet, useLocation } from 'react-router-dom'
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
import { Link } from '@/lib/router'
|
import { Link, useRouter } from '@/lib/router'
|
||||||
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText } from 'lucide-react'
|
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText, LogOut, FileBarChart } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/common/Badge'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
|
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
|
||||||
@@ -12,11 +15,34 @@ const NAV_ITEMS = [
|
|||||||
{ href: '/admin/alerts', label: 'اعلانها', icon: Bell, exact: false },
|
{ href: '/admin/alerts', label: 'اعلانها', icon: Bell, exact: false },
|
||||||
{ href: '/admin/posts', label: 'پستها', icon: MessageSquare, 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/reports', label: 'گزارشها', icon: FileBarChart, exact: false },
|
||||||
{ href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
|
{ href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type StoredUser = { id?: number; name?: string; family?: string }
|
||||||
|
|
||||||
|
function getStoredUser(): StoredUser {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('user')
|
||||||
|
return raw ? JSON.parse(raw) as StoredUser : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const pathname = useLocation().pathname
|
const pathname = useLocation().pathname
|
||||||
|
const router = useRouter()
|
||||||
|
const [user] = useState<StoredUser>(getStoredUser)
|
||||||
|
|
||||||
|
const fullName = `${user.name ?? ''} ${user.family ?? ''}`.trim() || 'مدیر'
|
||||||
|
const initial = (user.name || 'م').charAt(0)
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
const isActive = (item: (typeof NAV_ITEMS)[number]) =>
|
const isActive = (item: (typeof NAV_ITEMS)[number]) =>
|
||||||
item.exact ? pathname === item.href : pathname.startsWith(item.href)
|
item.exact ? pathname === item.href : pathname.startsWith(item.href)
|
||||||
@@ -46,34 +72,44 @@ export default function AdminLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fafafa] flex flex-col md:flex-row">
|
<div className="min-h-screen bg-[#fafafa] flex flex-col md:flex-row">
|
||||||
{/* Desktop sidebar (right side in RTL) */}
|
{/* 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">
|
<aside className="hidden md:flex flex-col w-60 shrink-0 border-l border-gray-200 bg-white p-4 sticky top-14 h-[calc(100vh-3.5rem)]">
|
||||||
<div className="px-3 py-4 mb-2 border-b border-gray-100">
|
<div className="px-3 py-4 mb-2 border-b border-gray-100">
|
||||||
<h1 className="text-base font-bold text-gray-900">پنل مدیریت</h1>
|
<h1 className="text-base font-bold text-gray-900">پنل مدیریت</h1>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">گ린هوم</p>
|
<p className="text-xs text-gray-400 mt-0.5">گ린هوم</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 flex flex-col gap-1">{navLinks()}</nav>
|
<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>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile top bar */}
|
{/* Sticky header (all breakpoints, merges the old mobile header) */}
|
||||||
<header className="md:hidden sticky top-0 z-20 bg-white border-b border-gray-200">
|
<div className="sticky top-0 z-20 w-full md:flex-1">
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<header className="bg-white border-b border-gray-200">
|
||||||
<h1 className="text-sm font-bold text-gray-900">پنل مدیریت</h1>
|
<div className="h-14 flex items-center justify-between px-4 md:px-6">
|
||||||
<Link href="/" className="text-xs font-medium text-gray-500 flex items-center gap-1">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<ArrowRight className="w-3.5 h-3.5" />
|
<span className="w-9 h-9 shrink-0 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-sm font-bold">
|
||||||
خروج
|
{initial}
|
||||||
</Link>
|
</span>
|
||||||
</div>
|
<span className="text-sm font-medium text-gray-900 truncate">{fullName}</span>
|
||||||
<nav className="flex gap-1 px-3 pb-2 overflow-x-auto">
|
<Badge variant="success" size="sm">ادمین</Badge>
|
||||||
{navLinks()}
|
</div>
|
||||||
</nav>
|
<div className="flex items-center gap-2">
|
||||||
</header>
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">بازگشت به برنامه</span>
|
||||||
|
</Link>
|
||||||
|
<Button variant="outline" size="sm" icon={LogOut} onClick={handleLogout}>
|
||||||
|
خروج
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* mobile horizontal nav */}
|
||||||
|
<nav className="md:hidden flex gap-1 px-3 pb-2 overflow-x-auto">
|
||||||
|
{navLinks()}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 p-4 md:p-6">
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
|
import { useCallback, useEffect, useState, type ChangeEvent } from 'react'
|
||||||
import { Bell, MessageSquare, Phone, AlertTriangle } from 'lucide-react'
|
import { Bell, MessageSquare, Phone, AlertTriangle } from 'lucide-react'
|
||||||
import { api, PagedResult, AdminAlertItem } from '@/lib/api'
|
import { api, PagedResult, AdminAlertItem, AdminDeviceListItem } 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 { Pagination } from '@/components/navigation/Pagination'
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
import { Badge } from '@/components/common/Badge'
|
import { Badge } from '@/components/common/Badge'
|
||||||
import { Button } from '@/components/common/Button'
|
import { Button } from '@/components/common/Button'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
import { SearchInput } from '@/components/forms/SearchInput'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -42,6 +43,8 @@ export default function AdminAlertsPage() {
|
|||||||
const [from, setFrom] = useState('')
|
const [from, setFrom] = useState('')
|
||||||
const [to, setTo] = useState('')
|
const [to, setTo] = useState('')
|
||||||
const [deviceId, setDeviceId] = useState('')
|
const [deviceId, setDeviceId] = useState('')
|
||||||
|
const [deviceSearch, setDeviceSearch] = useState('')
|
||||||
|
const [deviceResults, setDeviceResults] = useState<AdminDeviceListItem[]>([])
|
||||||
|
|
||||||
const fetchAlerts = useCallback(async (p: number, errorsOnly: boolean, f: string, t: string, devId: string) => {
|
const fetchAlerts = useCallback(async (p: number, errorsOnly: boolean, f: string, t: string, devId: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -72,6 +75,15 @@ export default function AdminAlertsPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchDevices = async (q: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.adminDevices({ search: q || undefined, page: 1, pageSize: 10 })
|
||||||
|
setDeviceResults(res.items)
|
||||||
|
} catch {
|
||||||
|
setDeviceResults([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const applyLast7Days = () => {
|
const applyLast7Days = () => {
|
||||||
setFrom(daysAgoIso(6))
|
setFrom(daysAgoIso(6))
|
||||||
setTo(new Date().toISOString().slice(0, 10))
|
setTo(new Date().toISOString().slice(0, 10))
|
||||||
@@ -82,6 +94,7 @@ export default function AdminAlertsPage() {
|
|||||||
setFrom('')
|
setFrom('')
|
||||||
setTo('')
|
setTo('')
|
||||||
setDeviceId('')
|
setDeviceId('')
|
||||||
|
setDeviceSearch('')
|
||||||
setOnlyErrors(false)
|
setOnlyErrors(false)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
@@ -118,14 +131,31 @@ export default function AdminAlertsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mr-auto">
|
<div className="flex items-center gap-2 mr-auto">
|
||||||
<input
|
<div className="relative w-52">
|
||||||
type="number"
|
<SearchInput
|
||||||
min={1}
|
value={deviceSearch}
|
||||||
value={deviceId}
|
onValueChange={(v) => { setDeviceSearch(v); searchDevices(v) }}
|
||||||
onChange={(e) => { setDeviceId(e.target.value); setPage(1) }}
|
onSubmit={(v) => searchDevices(v ?? deviceSearch)}
|
||||||
placeholder="شناسه دستگاه"
|
onClear={() => { setDeviceId(''); setDeviceResults([]); setPage(1) }}
|
||||||
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"
|
placeholder="فیلتر دستگاه..."
|
||||||
/>
|
showSearchButton={false}
|
||||||
|
/>
|
||||||
|
{deviceResults.length > 0 && (
|
||||||
|
<ul className="absolute z-10 top-full right-0 left-0 mt-1 bg-white border border-gray-200 rounded-lg divide-y divide-gray-100 shadow-sm max-h-40 overflow-y-auto">
|
||||||
|
{deviceResults.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-right px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
|
onClick={() => { setDeviceId(String(d.id)); setDeviceSearch(''); setDeviceResults([]); setPage(1) }}
|
||||||
|
>
|
||||||
|
{d.deviceName} <span className="text-xs text-gray-400" dir="ltr">#{toPersianDigits(d.id)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={from}
|
value={from}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { ScrollText } from 'lucide-react'
|
import { ScrollText } from 'lucide-react'
|
||||||
import { api, PagedResult, AdminAuditLogItem } from '@/lib/api'
|
import { api, PagedResult, AdminAuditLogItem, AdminUserListItem } 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 { Pagination } from '@/components/navigation/Pagination'
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
import { Badge } from '@/components/common/Badge'
|
import { Badge } from '@/components/common/Badge'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
import { SearchInput } from '@/components/forms/SearchInput'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -16,12 +17,20 @@ export default function AdminAuditPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [adminId, setAdminId] = useState('')
|
||||||
|
const [adminSearch, setAdminSearch] = useState('')
|
||||||
|
const [adminResults, setAdminResults] = useState<AdminUserListItem[]>([])
|
||||||
|
const [selectedAdmin, setSelectedAdmin] = useState<AdminUserListItem | null>(null)
|
||||||
|
|
||||||
const fetchLogs = useCallback(async (p: number) => {
|
const fetchLogs = useCallback(async (p: number, admId: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const data = await api.adminAuditLogs({ page: p, pageSize: PAGE_SIZE })
|
const data = await api.adminAuditLogs({
|
||||||
|
adminId: admId ? Number(admId) : undefined,
|
||||||
|
page: p,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
setResult(data)
|
setResult(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message || 'خطا در دریافت لاگ مدیریت')
|
setError((e as Error).message || 'خطا در دریافت لاگ مدیریت')
|
||||||
@@ -31,8 +40,25 @@ export default function AdminAuditPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs(page)
|
fetchLogs(page, adminId)
|
||||||
}, [page, fetchLogs])
|
}, [page, adminId, fetchLogs])
|
||||||
|
|
||||||
|
const searchAdmins = async (q: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.adminUsers({ search: q || undefined, page: 1, pageSize: 50 })
|
||||||
|
setAdminResults(res.items.filter((u) => u.role === 1))
|
||||||
|
} catch {
|
||||||
|
setAdminResults([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAdminFilter = () => {
|
||||||
|
setAdminId('')
|
||||||
|
setAdminSearch('')
|
||||||
|
setSelectedAdmin(null)
|
||||||
|
setAdminResults([])
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -48,6 +74,54 @@ export default function AdminAuditPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-64">
|
||||||
|
{selectedAdmin ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-sm">
|
||||||
|
<span className="text-green-800">
|
||||||
|
{selectedAdmin.name} {selectedAdmin.family}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-green-600 hover:text-green-800 text-xs"
|
||||||
|
onClick={clearAdminFilter}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SearchInput
|
||||||
|
value={adminSearch}
|
||||||
|
onValueChange={(v) => { setAdminSearch(v); searchAdmins(v) }}
|
||||||
|
onSubmit={(v) => searchAdmins(v ?? adminSearch)}
|
||||||
|
placeholder="فیلتر ادمین..."
|
||||||
|
showSearchButton={false}
|
||||||
|
/>
|
||||||
|
{adminResults.length > 0 && (
|
||||||
|
<ul className="absolute z-10 top-full right-0 left-0 mt-1 bg-white border border-gray-200 rounded-lg divide-y divide-gray-100 shadow-sm max-h-40 overflow-y-auto">
|
||||||
|
{adminResults.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-right px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
|
onClick={() => {
|
||||||
|
setAdminId(String(u.id))
|
||||||
|
setSelectedAdmin(u)
|
||||||
|
setAdminSearch('')
|
||||||
|
setAdminResults([])
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.name} {u.family} <span className="text-xs text-gray-400" dir="ltr">{toPersianDigits(u.mobile)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
|
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall } from 'lucide-react'
|
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall, Plus, Pencil, Users, Bell, BellOff } from 'lucide-react'
|
||||||
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult } from '@/lib/api'
|
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult, AdminDeviceMember, AdminUserListItem } from '@/lib/api'
|
||||||
|
import { resolveAssetUrl } from '@/lib/api/assets'
|
||||||
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 { getDeviceMemberRoleLabel } from '@/features/team/constants'
|
||||||
import { SearchInput } from '@/components/forms/SearchInput'
|
import { SearchInput } from '@/components/forms/SearchInput'
|
||||||
|
import { FormInput } from '@/components/forms/FormInput'
|
||||||
|
import { Select } from '@/components/forms/Select'
|
||||||
import { Pagination } from '@/components/navigation/Pagination'
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
import { Badge } from '@/components/common/Badge'
|
import { Badge } from '@/components/common/Badge'
|
||||||
import { Button } from '@/components/common/Button'
|
import { Button } from '@/components/common/Button'
|
||||||
@@ -22,6 +26,10 @@ 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 [members, setMembers] = useState<AdminDeviceMember[]>([])
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [testSms, setTestSms] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: 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 [testVoice, setTestVoice] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||||
|
|
||||||
@@ -50,16 +58,30 @@ export default function AdminDevicesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openDetail = async (id: number) => {
|
const openDetail = async (id: number) => {
|
||||||
|
setMembers([])
|
||||||
|
setMembersLoading(true)
|
||||||
try {
|
try {
|
||||||
const d = await api.adminDevice(id)
|
const d = await api.adminDevice(id)
|
||||||
setDetail(d)
|
setDetail(d)
|
||||||
setTestSms({ busy: false, result: null })
|
setTestSms({ busy: false, result: null })
|
||||||
setTestVoice({ busy: false, result: null })
|
setTestVoice({ busy: false, result: null })
|
||||||
|
try {
|
||||||
|
setMembers(await api.adminDeviceMembers(id))
|
||||||
|
} catch {
|
||||||
|
setMembers([])
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه')
|
setError((e as Error).message || 'خطا در دریافت جزئیات دستگاه')
|
||||||
|
} finally {
|
||||||
|
setMembersLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshDetail = async (id: number) => {
|
||||||
|
const d = await api.adminDevice(id)
|
||||||
|
setDetail(d)
|
||||||
|
}
|
||||||
|
|
||||||
const runTest = async (kind: 'sms' | 'voice', deviceId: number, setter: typeof setTestSms) => {
|
const runTest = async (kind: 'sms' | 'voice', deviceId: number, setter: typeof setTestSms) => {
|
||||||
setter({ busy: true, result: null })
|
setter({ busy: true, result: null })
|
||||||
try {
|
try {
|
||||||
@@ -77,7 +99,12 @@ export default function AdminDevicesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-base font-bold text-gray-900">دستگاهها</h2>
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-base font-bold text-gray-900">دستگاهها</h2>
|
||||||
|
<Button size="sm" icon={Plus} onClick={() => setCreateOpen(true)}>
|
||||||
|
ثبت دستگاه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{result && (
|
{result && (
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{toPersianDigits(result.totalCount)} دستگاه
|
{toPersianDigits(result.totalCount)} دستگاه
|
||||||
@@ -207,6 +234,12 @@ export default function AdminDevicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button variant="outline" size="sm" icon={Pencil} onClick={() => setEditOpen(true)}>
|
||||||
|
ویرایش مشخصات
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
<p className="text-xs text-gray-500 mb-2">تست اعلان</p>
|
<p className="text-xs text-gray-500 mb-2">تست اعلان</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -237,6 +270,60 @@ export default function AdminDevicesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-gray-400" />
|
||||||
|
اعضا ({toPersianDigits(members.length)})
|
||||||
|
</h4>
|
||||||
|
{membersLoading ? (
|
||||||
|
<p className="text-xs text-gray-400">در حال بارگذاری...</p>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-400">عضوی برای این دستگاه ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-gray-500 border-b border-gray-100 bg-gray-50">
|
||||||
|
<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-right font-medium py-2 px-3">نقش</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">اعلان</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((m) => (
|
||||||
|
<tr key={m.userId} className="border-b border-gray-50 last:border-0">
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{m.avatarPath ? (
|
||||||
|
<img src={resolveAssetUrl(m.avatarPath)} alt="" className="w-7 h-7 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="w-7 h-7 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-xs font-bold">
|
||||||
|
{(m.memberName || m.name).charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-900">{m.memberName || m.name} {m.memberFamily || m.family}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-left tabular-nums text-gray-500" dir="ltr">{toPersianDigits(m.mobile)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{m.isOwner
|
||||||
|
? <Badge variant="success" size="sm">مالک</Badge>
|
||||||
|
: <Badge size="sm">{getDeviceMemberRoleLabel(m.role)}</Badge>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
{m.receiveAlerts
|
||||||
|
? <Bell className="w-4 h-4 text-green-600" />
|
||||||
|
: <BellOff className="w-4 h-4 text-gray-300" />}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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" />
|
||||||
@@ -272,6 +359,261 @@ export default function AdminDevicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="ثبت دستگاه جدید" size="lg">
|
||||||
|
<CreateDeviceForm onCreated={() => { setCreateOpen(false); fetchDevices(1, appliedSearch) }} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={editOpen && detail !== null} onClose={() => setEditOpen(false)} title={detail ? `ویرایش مشخصات: ${detail.deviceName}` : ''} size="lg">
|
||||||
|
{detail && (
|
||||||
|
<EditDeviceSettingsForm
|
||||||
|
device={detail}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSaved={() => refreshDetail(detail.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreateDeviceForm({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const [ownerSearch, setOwnerSearch] = useState('')
|
||||||
|
const [ownerResults, setOwnerResults] = useState<AdminUserListItem[]>([])
|
||||||
|
const [owner, setOwner] = useState<AdminUserListItem | null>(null)
|
||||||
|
const [ownerSearching, setOwnerSearching] = useState(false)
|
||||||
|
const [ownerError, setOwnerError] = useState('')
|
||||||
|
const [deviceName, setDeviceName] = useState('')
|
||||||
|
const [location, setLocation] = useState('')
|
||||||
|
const [neshanLocation, setNeshanLocation] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ kind: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const searchOwners = async (q: string) => {
|
||||||
|
setOwnerSearching(true)
|
||||||
|
setOwnerError('')
|
||||||
|
try {
|
||||||
|
const res = await api.adminUsers({ search: q || undefined, page: 1, pageSize: 10 })
|
||||||
|
setOwnerResults(res.items)
|
||||||
|
} catch (e) {
|
||||||
|
setOwnerError((e as Error).message || 'خطا در دریافت کاربران')
|
||||||
|
} finally {
|
||||||
|
setOwnerSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!owner) { setMessage({ kind: 'error', text: 'لطفاً مالک دستگاه را انتخاب کنید.' }); return }
|
||||||
|
if (deviceName.trim().length === 0 || deviceName.trim().length > 10) {
|
||||||
|
setMessage({ kind: 'error', text: 'نام دستگاه باید بین ۱ تا ۱۰ کاراکتر باشد.' }); return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await api.adminCreateDevice({
|
||||||
|
userId: owner.id,
|
||||||
|
deviceName: deviceName.trim(),
|
||||||
|
location: location.trim(),
|
||||||
|
neshanLocation: neshanLocation.trim() || undefined,
|
||||||
|
})
|
||||||
|
setMessage({ kind: 'success', text: 'دستگاه با موفقیت ثبت شد.' })
|
||||||
|
setTimeout(onCreated, 600)
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ kind: 'error', text: (err as Error).message || 'خطایی در ثبت دستگاه رخ داد.' })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
{message && (
|
||||||
|
<p className={message.kind === 'success'
|
||||||
|
? 'text-sm text-green-700 bg-green-50 rounded-lg p-3'
|
||||||
|
: 'text-sm text-red-700 bg-red-50 rounded-lg p-3'}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">مالک دستگاه</label>
|
||||||
|
{owner ? (
|
||||||
|
<div className="flex items-center justify-between bg-green-50 border border-green-200 rounded-lg px-3 py-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{owner.name} {owner.family}</span>
|
||||||
|
<span className="text-xs text-gray-400" dir="ltr">{toPersianDigits(owner.mobile)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SearchInput
|
||||||
|
value={ownerSearch}
|
||||||
|
onValueChange={(v) => { setOwnerSearch(v); if (v !== undefined) searchOwners(v) }}
|
||||||
|
onSubmit={(v) => { const s = v ?? ownerSearch; setOwnerSearch(s); searchOwners(s) }}
|
||||||
|
placeholder="جستجو بر اساس نام یا موبایل..."
|
||||||
|
/>
|
||||||
|
{ownerSearching && <p className="text-xs text-gray-400">در حال جستجو...</p>}
|
||||||
|
{ownerError && <p className="text-xs text-red-600">{ownerError}</p>}
|
||||||
|
{ownerResults.length > 0 && (
|
||||||
|
<ul className="border border-gray-200 rounded-lg divide-y divide-gray-100 max-h-40 overflow-y-auto">
|
||||||
|
{ownerResults.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-right px-3 py-2 hover:bg-gray-50 flex items-center justify-between"
|
||||||
|
onClick={() => { setOwner(u); setOwnerResults([]) }}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-900">{u.name} {u.family}</span>
|
||||||
|
<span className="text-xs text-gray-400" dir="ltr">{toPersianDigits(u.mobile)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormInput
|
||||||
|
label="نام دستگاه (حداکثر ۱۰ کاراکتر)"
|
||||||
|
value={deviceName}
|
||||||
|
onChange={(e) => setDeviceName(e.target.value)}
|
||||||
|
maxLength={10}
|
||||||
|
placeholder="مثلاً: گلخانه ۱"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="موقعیت"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
placeholder="استان، شهر، بخش"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="موقعیت نشان (اختیاری)"
|
||||||
|
value={neshanLocation}
|
||||||
|
onChange={(e) => setNeshanLocation(e.target.value)}
|
||||||
|
placeholder="آدرس نشان"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{busy ? 'در حال ثبت...' : 'ثبت دستگاه'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditDeviceSettingsForm({ device, onClose, onSaved }: {
|
||||||
|
device: AdminDeviceDetail
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void> | void
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
deviceName: device.deviceName,
|
||||||
|
province: device.province ?? '',
|
||||||
|
city: device.city ?? '',
|
||||||
|
latitude: device.latitude != null ? String(device.latitude) : '',
|
||||||
|
longitude: device.longitude != null ? String(device.longitude) : '',
|
||||||
|
productType: device.productType ?? '',
|
||||||
|
devicePhoneNumber: device.devicePhoneNumber ?? '',
|
||||||
|
simCardType: device.simCardType != null ? String(device.simCardType) : '',
|
||||||
|
relayCount: String(device.relayCount ?? 4),
|
||||||
|
smsRecievers: device.smsRecievers ?? '',
|
||||||
|
minimumSmsIntervalMinutes: String(device.minimumSmsIntervalMinutes ?? 15),
|
||||||
|
minimumCallIntervalMinutes: String(device.minimumCallIntervalMinutes ?? 60),
|
||||||
|
uploadIntervalMin: String(device.uploadIntervalMin ?? 5),
|
||||||
|
areaSquareMeters: device.areaSquareMeters != null ? String(device.areaSquareMeters) : '',
|
||||||
|
})
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ kind: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
|
||||||
|
setForm(f => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await api.adminUpdateDeviceSettings(device.id, {
|
||||||
|
deviceName: form.deviceName.trim(),
|
||||||
|
province: form.province.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
latitude: form.latitude.trim() ? parseFloat(form.latitude) : null,
|
||||||
|
longitude: form.longitude.trim() ? parseFloat(form.longitude) : null,
|
||||||
|
productType: form.productType.trim(),
|
||||||
|
minimumSmsIntervalMinutes: Number(form.minimumSmsIntervalMinutes),
|
||||||
|
minimumCallIntervalMinutes: Number(form.minimumCallIntervalMinutes),
|
||||||
|
uploadIntervalMin: Number(form.uploadIntervalMin),
|
||||||
|
areaSquareMeters: form.areaSquareMeters.trim() ? parseFloat(form.areaSquareMeters) : null,
|
||||||
|
devicePhoneNumber: form.devicePhoneNumber.trim(),
|
||||||
|
simCardType: form.simCardType === '' ? null : Number(form.simCardType),
|
||||||
|
relayCount: Number(form.relayCount),
|
||||||
|
smsRecievers: form.smsRecievers.trim(),
|
||||||
|
})
|
||||||
|
setMessage({ kind: 'success', text: 'مشخصات دستگاه با موفقیت بهروزرسانی شد.' })
|
||||||
|
await onSaved()
|
||||||
|
setTimeout(onClose, 500)
|
||||||
|
} catch (err) {
|
||||||
|
setMessage({ kind: 'error', text: (err as Error).message || 'خطایی در بهروزرسانی رخ داد.' })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
{message && (
|
||||||
|
<p className={message.kind === 'success'
|
||||||
|
? 'text-sm text-green-700 bg-green-50 rounded-lg p-3'
|
||||||
|
: 'text-sm text-red-700 bg-red-50 rounded-lg p-3'}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormInput label="نام دستگاه (حداکثر ۱۰ کاراکتر)" value={form.deviceName}
|
||||||
|
onChange={(e) => set('deviceName', e.target.value)} maxLength={10} required />
|
||||||
|
<FormInput label="نوع محصول" value={form.productType}
|
||||||
|
onChange={(e) => set('productType', e.target.value)} />
|
||||||
|
<FormInput label="استان" value={form.province}
|
||||||
|
onChange={(e) => set('province', e.target.value)} />
|
||||||
|
<FormInput label="شهر" value={form.city}
|
||||||
|
onChange={(e) => set('city', e.target.value)} />
|
||||||
|
<FormInput label="عرض جغرافیایی (اختیاری)" value={form.latitude}
|
||||||
|
onChange={(e) => set('latitude', e.target.value)} dir="ltr" inputMode="decimal" placeholder="31.42..." />
|
||||||
|
<FormInput label="طول جغرافیایی (اختیاری)" value={form.longitude}
|
||||||
|
onChange={(e) => set('longitude', e.target.value)} dir="ltr" inputMode="decimal" placeholder="51.65..." />
|
||||||
|
<FormInput label="شماره موبایل دستگاه" value={form.devicePhoneNumber}
|
||||||
|
onChange={(e) => set('devicePhoneNumber', e.target.value)} dir="ltr" inputMode="tel" />
|
||||||
|
<FormInput label="مساحت (متر مربع، اختیاری)" value={form.areaSquareMeters}
|
||||||
|
onChange={(e) => set('areaSquareMeters', e.target.value)} dir="ltr" inputMode="decimal" />
|
||||||
|
<FormInput label="فاصله پیامک (دقیقه)" type="number" min={1} value={form.minimumSmsIntervalMinutes}
|
||||||
|
onChange={(e) => set('minimumSmsIntervalMinutes', e.target.value)} />
|
||||||
|
<FormInput label="فاصله تماس (دقیقه)" type="number" min={1} value={form.minimumCallIntervalMinutes}
|
||||||
|
onChange={(e) => set('minimumCallIntervalMinutes', e.target.value)} />
|
||||||
|
<FormInput label="بازه بارگذاری (دقیقه)" type="number" min={1} value={form.uploadIntervalMin}
|
||||||
|
onChange={(e) => set('uploadIntervalMin', e.target.value)} />
|
||||||
|
<FormInput label="تعداد رله (۱ تا ۱۶)" type="number" min={1} max={16} value={form.relayCount}
|
||||||
|
onChange={(e) => set('relayCount', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select label="نوع سیمکارت" value={form.simCardType}
|
||||||
|
onChange={(e) => set('simCardType', e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value={1}>همراه اول</option>
|
||||||
|
<option value={2}>ایرانسل</option>
|
||||||
|
<option value={3}>رایتل</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<FormInput label="دریافتکنندههای پیامک (با ویرگول جدا کنید)" value={form.smsRecievers}
|
||||||
|
onChange={(e) => set('smsRecievers', e.target.value)} dir="ltr" placeholder="0912...,0935..." />
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{busy ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { ChangeEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2 } from 'lucide-react'
|
import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2, Eye } from 'lucide-react'
|
||||||
import { api, PagedResult, AdminPostItem } from '@/lib/api'
|
import { api, PagedResult, AdminPostItem, AdminPostDetail } from '@/lib/api'
|
||||||
|
import { resolveAssetUrl } from '@/lib/api/assets'
|
||||||
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 { formatPersianTime } from '@/lib/format/persian-date'
|
||||||
|
import { SearchInput } from '@/components/forms/SearchInput'
|
||||||
import { Pagination } from '@/components/navigation/Pagination'
|
import { Pagination } from '@/components/navigation/Pagination'
|
||||||
import { Badge } from '@/components/common/Badge'
|
import { Badge } from '@/components/common/Badge'
|
||||||
import { Button } from '@/components/common/Button'
|
import { Button } from '@/components/common/Button'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
import { SegmentTab } from '@/components/common/SegmentTab'
|
||||||
|
import { Modal } from '@/components/common/Modal'
|
||||||
import { useConfirmDialog } from '@/components/utils/ConfirmDialog'
|
import { useConfirmDialog } from '@/components/utils/ConfirmDialog'
|
||||||
|
import type { AdminDeviceListItem, AdminUserListItem } from '@/lib/api'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -21,6 +27,25 @@ const POST_TYPE_META: Record<number, { label: string; icon: typeof MessageSquare
|
|||||||
3: { label: 'اسنپشات', icon: Camera },
|
3: { label: 'اسنپشات', icon: Camera },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PostTypeFilter = 'all' | 'text' | 'image' | 'voice' | 'snapshot'
|
||||||
|
const POST_TYPE_TABS: { value: PostTypeFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'همه' },
|
||||||
|
{ value: 'text', label: 'متن' },
|
||||||
|
{ value: 'image', label: 'تصویر' },
|
||||||
|
{ value: 'voice', label: 'ویس' },
|
||||||
|
{ value: 'snapshot', label: 'اسنپشات' },
|
||||||
|
]
|
||||||
|
const TAB_TO_POST_TYPE: Record<Exclude<PostTypeFilter, 'all'>, number> = {
|
||||||
|
text: 0, image: 1, voice: 2, snapshot: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVoiceDuration(seconds?: number | null): string {
|
||||||
|
if (seconds == null || !isFinite(seconds)) return ''
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${toPersianDigits(m)}:${toPersianDigits(String(s).padStart(2, '0'))}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPostsPage() {
|
export default function AdminPostsPage() {
|
||||||
const [result, setResult] = useState<PagedResult<AdminPostItem> | null>(null)
|
const [result, setResult] = useState<PagedResult<AdminPostItem> | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -29,11 +54,35 @@ export default function AdminPostsPage() {
|
|||||||
const [busyId, setBusyId] = useState<number | null>(null)
|
const [busyId, setBusyId] = useState<number | null>(null)
|
||||||
const { confirm, ConfirmDialog } = useConfirmDialog()
|
const { confirm, ConfirmDialog } = useConfirmDialog()
|
||||||
|
|
||||||
const fetchPosts = useCallback(async (p: number) => {
|
// filters
|
||||||
|
const [postTypeTab, setPostTypeTab] = useState<PostTypeFilter>('all')
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const [deviceSearch, setDeviceSearch] = useState('')
|
||||||
|
const [deviceResults, setDeviceResults] = useState<AdminDeviceListItem[]>([])
|
||||||
|
const [deviceId, setDeviceId] = useState('')
|
||||||
|
const [authorSearch, setAuthorSearch] = useState('')
|
||||||
|
const [authorResults, setAuthorResults] = useState<AdminUserListItem[]>([])
|
||||||
|
const [authorUserId, setAuthorUserId] = useState('')
|
||||||
|
|
||||||
|
// detail modal
|
||||||
|
const [detail, setDetail] = useState<AdminPostDetail | null>(null)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchPosts = useCallback(async (p: number, tab: PostTypeFilter, f: string, t: string, devId: string, authorId: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const data = await api.adminPosts({ page: p, pageSize: PAGE_SIZE })
|
const data = await api.adminPosts({
|
||||||
|
page: p,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
postType: tab === 'all' ? undefined : TAB_TO_POST_TYPE[tab],
|
||||||
|
from: f || undefined,
|
||||||
|
to: t ? `${t}T23:59:59` : undefined,
|
||||||
|
deviceId: devId ? Number(devId) : undefined,
|
||||||
|
authorUserId: authorId ? Number(authorId) : undefined,
|
||||||
|
})
|
||||||
setResult(data)
|
setResult(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message || 'خطا در دریافت پستها')
|
setError((e as Error).message || 'خطا در دریافت پستها')
|
||||||
@@ -43,27 +92,74 @@ export default function AdminPostsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPosts(page)
|
fetchPosts(page, postTypeTab, from, to, deviceId, authorUserId)
|
||||||
}, [page, fetchPosts])
|
}, [page, postTypeTab, from, to, deviceId, authorUserId, fetchPosts])
|
||||||
|
|
||||||
const handleDelete = async (post: AdminPostItem) => {
|
const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setter(e.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setPostTypeTab('all')
|
||||||
|
setFrom('')
|
||||||
|
setTo('')
|
||||||
|
setDeviceId('')
|
||||||
|
setAuthorUserId('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = postTypeTab !== 'all' || from || to || deviceId || authorUserId
|
||||||
|
|
||||||
|
const searchDevices = async (q: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.adminDevices({ search: q || undefined, page: 1, pageSize: 10 })
|
||||||
|
setDeviceResults(res.items)
|
||||||
|
} catch {
|
||||||
|
setDeviceResults([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchAuthors = async (q: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api.adminUsers({ search: q || undefined, page: 1, pageSize: 10 })
|
||||||
|
setAuthorResults(res.items)
|
||||||
|
} catch {
|
||||||
|
setAuthorResults([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetail = async (post: AdminPostItem) => {
|
||||||
|
setDetailLoading(true)
|
||||||
|
setDetail(null)
|
||||||
|
try {
|
||||||
|
setDetail(await api.adminPost(post.id))
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در دریافت جزئیات پست')
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (postId: number) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'حذف پست',
|
title: 'حذف پست',
|
||||||
message: `این پست بهطور کامل حذف میشود (با تصاویر آن). ادامه میدهید؟`,
|
message: 'این پست بهطور کامل حذف میشود (با تصاویر آن). ادامه میدهید؟',
|
||||||
confirmText: 'حذف',
|
confirmText: 'حذف',
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
})
|
})
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|
||||||
setBusyId(post.id)
|
setBusyId(postId)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await api.adminDeletePost(post.id)
|
await api.adminDeletePost(postId)
|
||||||
|
setDetail(null)
|
||||||
// if the current page becomes empty, step back a page
|
// if the current page becomes empty, step back a page
|
||||||
if (result && result.items.length === 1 && page > 1) {
|
if (result && result.items.length === 1 && page > 1) {
|
||||||
setPage(page - 1)
|
setPage(page - 1)
|
||||||
} else {
|
} else {
|
||||||
fetchPosts(page)
|
fetchPosts(page, postTypeTab, from, to, deviceId, authorUserId)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message || 'خطا در حذف پست')
|
setError((e as Error).message || 'خطا در حذف پست')
|
||||||
@@ -86,6 +182,80 @@ export default function AdminPostsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 bg-white rounded-xl border border-gray-200 p-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<SegmentTab tabs={POST_TYPE_TABS} activeTab={postTypeTab} setActiveTab={(v) => { setPostTypeTab(v); setPage(1) }} />
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
پاک کردن فیلترها
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
|
<div className="relative w-52">
|
||||||
|
<SearchInput
|
||||||
|
value={deviceSearch}
|
||||||
|
onValueChange={(v) => { setDeviceSearch(v); if (v !== undefined) searchDevices(v) }}
|
||||||
|
onSubmit={(v) => { const s = v ?? deviceSearch; setDeviceSearch(s); searchDevices(s) }}
|
||||||
|
placeholder="فیلتر دستگاه..."
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
{deviceResults.length > 0 && (
|
||||||
|
<ul className="absolute z-10 top-full right-0 left-0 mt-1 bg-white border border-gray-200 rounded-lg divide-y divide-gray-100 shadow-sm max-h-40 overflow-y-auto">
|
||||||
|
{deviceResults.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-right px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
|
onClick={() => { setDeviceId(String(d.id)); setDeviceResults([]); setPage(1) }}
|
||||||
|
>
|
||||||
|
{d.deviceName} <span className="text-xs text-gray-400" dir="ltr">#{toPersianDigits(d.id)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-52">
|
||||||
|
<SearchInput
|
||||||
|
value={authorSearch}
|
||||||
|
onValueChange={(v) => { setAuthorSearch(v); if (v !== undefined) searchAuthors(v) }}
|
||||||
|
onSubmit={(v) => { const s = v ?? authorSearch; setAuthorSearch(s); searchAuthors(s) }}
|
||||||
|
placeholder="فیلتر نویسنده..."
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
{authorResults.length > 0 && (
|
||||||
|
<ul className="absolute z-10 top-full right-0 left-0 mt-1 bg-white border border-gray-200 rounded-lg divide-y divide-gray-100 shadow-sm max-h-40 overflow-y-auto">
|
||||||
|
{authorResults.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-right px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
|
onClick={() => { setAuthorUserId(String(u.id)); setAuthorResults([]); setPage(1) }}
|
||||||
|
>
|
||||||
|
{u.name} {u.family} <span className="text-xs text-gray-400" dir="ltr">{toPersianDigits(u.mobile)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
onChange={onDateChange(setFrom)}
|
||||||
|
className="text-xs border border-gray-200 rounded-lg px-2 py-2 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400 pb-2">تا</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
onChange={onDateChange(setTo)}
|
||||||
|
className="text-xs border border-gray-200 rounded-lg px-2 py-2 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>}
|
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -124,8 +294,15 @@ export default function AdminPostsPage() {
|
|||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<Badge variant="default" size="sm" icon={TypeIcon}>{meta.label}</Badge>
|
<Badge variant="default" size="sm" icon={TypeIcon}>{meta.label}</Badge>
|
||||||
{post.imageCount > 0 && (
|
{post.imageCount > 0 && (
|
||||||
<span className="text-xs text-gray-400 mr-1">
|
<span className="inline-flex items-center gap-1 text-xs text-gray-400 mr-2">
|
||||||
{toPersianDigits(post.imageCount)} تصویر
|
<ImageIcon className="w-3.5 h-3.5" />
|
||||||
|
{toPersianDigits(post.imageCount)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{post.hasVoice && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-400 mr-2">
|
||||||
|
<Mic className="w-3.5 h-3.5" />
|
||||||
|
{formatVoiceDuration(post.voiceDurationSeconds)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -136,10 +313,15 @@ export default function AdminPostsPage() {
|
|||||||
{formatPersianDateFromIso(post.createdAt)}
|
{formatPersianDateFromIso(post.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<Button variant="ghost" size="sm" icon={Trash2} disabled={busyId === post.id}
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => handleDelete(post)} tooltip="حذف پست">
|
<Button variant="ghost" size="sm" icon={Eye} onClick={() => openDetail(post)} tooltip="جزئیات پست">
|
||||||
حذف
|
جزئیات
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" icon={Trash2} disabled={busyId === post.id}
|
||||||
|
onClick={() => handleDelete(post.id)} tooltip="حذف پست">
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -155,10 +337,83 @@ export default function AdminPostsPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
title="پستی یافت نشد"
|
title="پستی یافت نشد"
|
||||||
message="هنوز پستی ثبت نشده است."
|
message="هنوز پستی ثبت نشده است یا فیلترها نتیجهای ندارند."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={detail !== null || detailLoading} onClose={() => setDetail(null)} title="جزئیات پست" size="lg">
|
||||||
|
{detailLoading && <p className="text-sm text-gray-400 py-8 text-center">در حال بارگذاری...</p>}
|
||||||
|
{detail && (() => {
|
||||||
|
const meta = POST_TYPE_META[detail.postType] ?? POST_TYPE_META[0]
|
||||||
|
const TypeIcon = meta.icon
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{detail.authorName} {detail.authorFamily}
|
||||||
|
<span className="text-xs text-gray-400 font-normal mr-2">— دستگاه: {detail.deviceName}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{formatPersianDateFromIso(detail.createdAt)}
|
||||||
|
{formatPersianTime(detail.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="default" size="sm" icon={TypeIcon}>{meta.label}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.content && (
|
||||||
|
<p className="whitespace-pre-wrap text-gray-800 bg-gray-50 rounded-lg p-3">{detail.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.images.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{detail.images.map((img) => (
|
||||||
|
<button key={img.id} type="button" className="relative group" onClick={() => setLightbox(resolveAssetUrl(img.filePath))}>
|
||||||
|
<img
|
||||||
|
src={resolveAssetUrl(img.filePath)}
|
||||||
|
alt={img.fileName}
|
||||||
|
className="w-full h-28 object-cover rounded-lg border border-gray-200 group-hover:opacity-80 transition-opacity"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.voiceFilePath && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500 mb-2 flex items-center gap-1">
|
||||||
|
<Mic className="w-3.5 h-3.5" />
|
||||||
|
پیام صوتی
|
||||||
|
{detail.voiceDurationSeconds != null && (
|
||||||
|
<span className="text-gray-400">({formatVoiceDuration(detail.voiceDurationSeconds)})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<audio controls src={resolveAssetUrl(detail.voiceFilePath)} className="w-full h-10" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="danger" size="sm" icon={Trash2} disabled={busyId === detail.id}
|
||||||
|
onClick={() => handleDelete(detail.id)}>
|
||||||
|
حذف پست
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* lightbox */}
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] bg-black/80 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
>
|
||||||
|
<img src={lightbox} alt="" className="max-w-full max-h-full object-contain rounded-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
240
src/GreenHome.Web/src/app/admin/reports/page.tsx
Normal file
240
src/GreenHome.Web/src/app/admin/reports/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ChangeEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { FileBarChart, Download, TrendingUp, Activity, Users } from 'lucide-react'
|
||||||
|
import { api, AdminUserGrowthRow, AdminDeviceUptimeRow, AdminUsageByUserRow } from '@/lib/api'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
|
import { SegmentTab } from '@/components/common/SegmentTab'
|
||||||
|
import { Card } from '@/components/common/Card'
|
||||||
|
|
||||||
|
type ReportTab = 'users' | 'uptime' | 'usage'
|
||||||
|
const TABS: { value: ReportTab; label: string }[] = [
|
||||||
|
{ value: 'users', label: 'رشد کاربران' },
|
||||||
|
{ value: 'uptime', label: 'آنلاینی دستگاهها' },
|
||||||
|
{ value: 'usage', label: 'مصرف بهازای کاربر' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type RangePreset = 7 | 30 | 90
|
||||||
|
|
||||||
|
function daysAgo(n: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - n)
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminReportsPage() {
|
||||||
|
const [tab, setTab] = useState<ReportTab>('users')
|
||||||
|
const [from, setFrom] = useState(daysAgo(6))
|
||||||
|
const [to, setTo] = useState(new Date().toISOString().slice(0, 10))
|
||||||
|
const [preset, setPreset] = useState<RangePreset>(7)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [csvBusy, setCsvBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [usersRows, setUsersRows] = useState<AdminUserGrowthRow[]>([])
|
||||||
|
const [uptimeRows, setUptimeRows] = useState<AdminDeviceUptimeRow[]>([])
|
||||||
|
const [usageRows, setUsageRows] = useState<AdminUsageByUserRow[]>([])
|
||||||
|
|
||||||
|
const fetchReport = useCallback(async (t: ReportTab, f: string, t2: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const q = { from: f || undefined, to: t2 || undefined }
|
||||||
|
if (t === 'users') setUsersRows(await api.adminReportUsers(q))
|
||||||
|
else if (t === 'uptime') setUptimeRows(await api.adminReportDeviceUptime(q))
|
||||||
|
else setUsageRows(await api.adminReportUsageByUser(q))
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در دریافت گزارش')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReport(tab, from, to)
|
||||||
|
}, [tab, from, to, fetchReport])
|
||||||
|
|
||||||
|
const applyPreset = (n: RangePreset) => {
|
||||||
|
setPreset(n)
|
||||||
|
setFrom(daysAgo(n - 1))
|
||||||
|
setTo(new Date().toISOString().slice(0, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setter(e.target.value)
|
||||||
|
setPreset(0 as unknown as RangePreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadCsv = async () => {
|
||||||
|
setCsvBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const path = tab === 'users' ? 'users' : tab === 'uptime' ? 'devices-uptime' : 'usage-by-user'
|
||||||
|
await api.adminReportCsv(path, { from: from || undefined, to: to || undefined })
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message || 'خطا در دریافت گزارش CSV')
|
||||||
|
} finally {
|
||||||
|
setCsvBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCards = tab === 'users'
|
||||||
|
? [
|
||||||
|
{ icon: Users, label: 'کل کاربران جدید', value: usersRows.reduce((a, r) => a + r.newUsers, 0) },
|
||||||
|
{ icon: TrendingUp, label: 'بیشترین کاربران جدید در یک روز', value: usersRows.reduce((m, r) => Math.max(m, r.newUsers), 0) },
|
||||||
|
]
|
||||||
|
: tab === 'uptime'
|
||||||
|
? [
|
||||||
|
{ icon: Activity, label: 'تعداد دستگاهها', value: uptimeRows.length },
|
||||||
|
{ icon: TrendingUp, label: 'میانگین درصد آنلاین', value: uptimeRows.length ? Math.round(uptimeRows.reduce((a, r) => a + r.onlinePercent, 0) / uptimeRows.length) : 0 },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ icon: Users, label: 'تعداد کاربران فعال', value: usageRows.length },
|
||||||
|
{ icon: TrendingUp, label: 'مجموع پیامک', value: usageRows.reduce((a, r) => a + r.sms, 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>
|
||||||
|
<Button variant="outline" size="sm" icon={Download} disabled={csvBusy || loading} onClick={downloadCsv}>
|
||||||
|
{csvBusy ? 'در حال تهیه...' : 'دریافت CSV'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SegmentTab tabs={TABS} activeTab={tab} setActiveTab={(v) => { setTab(v); setPreset(7); setFrom(daysAgo(6)); setTo(new Date().toISOString().slice(0, 10)) }} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{([7, 30, 90] as RangePreset[]).map((n) => (
|
||||||
|
<Button key={n} variant={preset === n ? 'default' : 'ghost'} size="sm" onClick={() => applyPreset(n)}>
|
||||||
|
{toPersianDigits(n)} روز اخیر
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2 mr-auto">
|
||||||
|
<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="grid grid-cols-2 gap-3">
|
||||||
|
{summaryCards.map((c) => (
|
||||||
|
<Card key={c.label} className="p-4 flex items-center gap-3">
|
||||||
|
<span className="w-10 h-10 rounded-lg bg-green-50 text-green-600 flex items-center justify-center shrink-0">
|
||||||
|
<c.icon className="w-5 h-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">{c.label}</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{toPersianDigits(c.value)}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
) : tab === 'users' ? (
|
||||||
|
usersRows.length === 0 ? (
|
||||||
|
<EmptyState icon={FileBarChart} title="دادهای نیست" message="برای این بازه زمانی دادهای ثبت نشده است." />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...usersRows].reverse().map((r) => (
|
||||||
|
<tr key={r.date} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="py-3 px-4 text-gray-700" dir="ltr">{r.date}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.newUsers)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.activeUsers)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : tab === 'uptime' ? (
|
||||||
|
uptimeRows.length === 0 ? (
|
||||||
|
<EmptyState icon={FileBarChart} title="دستگاهی نیست" message="برای این بازه زمانی دستگاهی ثبت نشده است." />
|
||||||
|
) : (
|
||||||
|
<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-left font-medium py-3 px-4">روزهای آنلاین</th>
|
||||||
|
<th className="text-left font-medium py-3 px-4">درصد آنلاین</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{uptimeRows.map((r) => (
|
||||||
|
<tr key={r.deviceId} 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">{r.deviceName}</td>
|
||||||
|
<td className="py-3 px-4 text-gray-700">{r.ownerName}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.totalDays)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.onlineDays)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums">
|
||||||
|
<span className={r.onlinePercent >= 70 ? 'text-green-700' : r.onlinePercent >= 40 ? 'text-yellow-700' : 'text-red-700'}>
|
||||||
|
{toPersianDigits(r.onlinePercent)}٪
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
usageRows.length === 0 ? (
|
||||||
|
<EmptyState icon={FileBarChart} title="دادهای نیست" message="برای این بازه زمانی مصرفی ثبت نشده است." />
|
||||||
|
) : (
|
||||||
|
<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">تماس صوتی</th>
|
||||||
|
<th className="text-left font-medium py-3 px-4">درخواست هوش مصنوعی</th>
|
||||||
|
<th className="text-left font-medium py-3 px-4">توکن هوش مصنوعی</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{usageRows.map((r) => (
|
||||||
|
<tr key={r.userId} 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">{r.name} {r.family}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-500" dir="ltr">{toPersianDigits(r.mobile)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.sms)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.voiceCall)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.aiQueries)}</td>
|
||||||
|
<td className="py-3 px-4 text-left tabular-nums text-gray-900">{toPersianDigits(r.aiTokens)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,11 +23,18 @@ import type {
|
|||||||
AdminUserDetail,
|
AdminUserDetail,
|
||||||
AdminDeviceListItem,
|
AdminDeviceListItem,
|
||||||
AdminDeviceDetail,
|
AdminDeviceDetail,
|
||||||
|
AdminDeviceCreateRequest,
|
||||||
|
AdminDeviceSettingsRequest,
|
||||||
|
AdminDeviceMember,
|
||||||
AdminUsageDay,
|
AdminUsageDay,
|
||||||
AdminAlertItem,
|
AdminAlertItem,
|
||||||
AdminPostItem,
|
AdminPostItem,
|
||||||
|
AdminPostDetail,
|
||||||
AdminDeviceTestResult,
|
AdminDeviceTestResult,
|
||||||
AdminAuditLogItem,
|
AdminAuditLogItem,
|
||||||
|
AdminUserGrowthRow,
|
||||||
|
AdminDeviceUptimeRow,
|
||||||
|
AdminUsageByUserRow,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||||
@@ -236,6 +243,12 @@ export const api = {
|
|||||||
return http<PagedResult<AdminDeviceListItem>>(`${API_BASE}/api/admin/devices?${params.toString()}`)
|
return http<PagedResult<AdminDeviceListItem>>(`${API_BASE}/api/admin/devices?${params.toString()}`)
|
||||||
},
|
},
|
||||||
adminDevice: (id: number) => http<AdminDeviceDetail>(`${API_BASE}/api/admin/devices/${id}`),
|
adminDevice: (id: number) => http<AdminDeviceDetail>(`${API_BASE}/api/admin/devices/${id}`),
|
||||||
|
adminCreateDevice: (req: AdminDeviceCreateRequest) =>
|
||||||
|
http<{ id: number; deviceName: string }>(`${API_BASE}/api/admin/devices`, { method: 'POST', body: JSON.stringify(req) }),
|
||||||
|
adminDeviceSettings: (id: number) => http<AdminDeviceDetail>(`${API_BASE}/api/admin/devices/${id}/settings`),
|
||||||
|
adminUpdateDeviceSettings: (id: number, req: AdminDeviceSettingsRequest) =>
|
||||||
|
http<void>(`${API_BASE}/api/admin/devices/${id}/settings`, { method: 'PUT', body: JSON.stringify(req) }),
|
||||||
|
adminDeviceMembers: (id: number) => http<AdminDeviceMember[]>(`${API_BASE}/api/admin/devices/${id}/members`),
|
||||||
adminUsage: (q: { from?: string; to?: string; userId?: number }) => {
|
adminUsage: (q: { from?: string; to?: string; userId?: number }) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (q.from) params.set('from', q.from)
|
if (q.from) params.set('from', q.from)
|
||||||
@@ -253,13 +266,18 @@ export const api = {
|
|||||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||||
return http<PagedResult<AdminAlertItem>>(`${API_BASE}/api/admin/alerts?${params.toString()}`)
|
return http<PagedResult<AdminAlertItem>>(`${API_BASE}/api/admin/alerts?${params.toString()}`)
|
||||||
},
|
},
|
||||||
adminPosts: (q: { deviceId?: number; page?: number; pageSize?: number }) => {
|
adminPosts: (q: { deviceId?: number; authorUserId?: number; postType?: number; from?: string; to?: string; page?: number; pageSize?: number }) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
||||||
|
if (q.authorUserId) params.set('authorUserId', String(q.authorUserId))
|
||||||
|
if (q.postType != null) params.set('postType', String(q.postType))
|
||||||
|
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.page) params.set('page', String(q.page))
|
||||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||||
return http<PagedResult<AdminPostItem>>(`${API_BASE}/api/admin/posts?${params.toString()}`)
|
return http<PagedResult<AdminPostItem>>(`${API_BASE}/api/admin/posts?${params.toString()}`)
|
||||||
},
|
},
|
||||||
|
adminPost: (id: number) => http<AdminPostDetail>(`${API_BASE}/api/admin/posts/${id}`),
|
||||||
adminDeletePost: (id: number) =>
|
adminDeletePost: (id: number) =>
|
||||||
http<void>(`${API_BASE}/api/admin/posts/${id}`, { method: 'DELETE' }),
|
http<void>(`${API_BASE}/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||||
adminTestSms: (deviceId: number) =>
|
adminTestSms: (deviceId: number) =>
|
||||||
@@ -273,4 +291,46 @@ export const api = {
|
|||||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||||
return http<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)
|
return http<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)
|
||||||
},
|
},
|
||||||
|
adminReportUsers: (q: { from?: string; to?: string }) =>
|
||||||
|
http<AdminUserGrowthRow[]>(`${API_BASE}/api/admin/reports/users${ReportParams(q)}`),
|
||||||
|
adminReportDeviceUptime: (q: { from?: string; to?: string }) =>
|
||||||
|
http<AdminDeviceUptimeRow[]>(`${API_BASE}/api/admin/reports/devices-uptime${ReportParams(q)}`),
|
||||||
|
adminReportUsageByUser: (q: { from?: string; to?: string }) =>
|
||||||
|
http<AdminUsageByUserRow[]>(`${API_BASE}/api/admin/reports/usage-by-user${ReportParams(q)}`),
|
||||||
|
adminReportCsv: async (path: string, q: { from?: string; to?: string }) => {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/reports/${path}${ReportParams(q)}&csv=1`, {
|
||||||
|
headers: csvAuthHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
let message = 'دریافت گزارش با خطا مواجه شد'
|
||||||
|
try { message = JSON.parse(text)?.message ?? message } catch { /* keep default */ }
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
const disposition = res.headers.get('Content-Disposition') ?? ''
|
||||||
|
const match = disposition.match(/filename="?([^";]+)"?/)
|
||||||
|
const filename = match?.[1] ?? `report-${path}-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportParams(q: { from?: string; to?: string }): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (q.from) params.set('from', q.from)
|
||||||
|
if (q.to) params.set('to', q.to)
|
||||||
|
const s = params.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvAuthHeaders(): Record<string, string> {
|
||||||
|
const token = getAuthToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,9 +331,54 @@ export type AdminDeviceDetail = {
|
|||||||
devicePhoneNumber?: string
|
devicePhoneNumber?: string
|
||||||
uploadIntervalMin?: number
|
uploadIntervalMin?: number
|
||||||
relayCount?: number
|
relayCount?: number
|
||||||
|
neshanLocation?: string
|
||||||
|
latitude?: number
|
||||||
|
longitude?: number
|
||||||
|
minimumSmsIntervalMinutes?: number
|
||||||
|
minimumCallIntervalMinutes?: number
|
||||||
|
areaSquareMeters?: number
|
||||||
|
simCardType?: number
|
||||||
|
smsRecievers?: string
|
||||||
lastTelemetry: TelemetryDto[]
|
lastTelemetry: TelemetryDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminDeviceCreateRequest = {
|
||||||
|
userId: number
|
||||||
|
deviceName: string
|
||||||
|
location: string
|
||||||
|
neshanLocation?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminDeviceSettingsRequest = {
|
||||||
|
deviceName: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
productType: string
|
||||||
|
minimumSmsIntervalMinutes: number
|
||||||
|
minimumCallIntervalMinutes: number
|
||||||
|
areaSquareMeters?: number | null
|
||||||
|
uploadIntervalMin: number
|
||||||
|
devicePhoneNumber: string
|
||||||
|
simCardType?: number | null
|
||||||
|
relayCount: number
|
||||||
|
smsRecievers: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminDeviceMember = {
|
||||||
|
userId: number
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
mobile: string
|
||||||
|
memberName: string
|
||||||
|
memberFamily: string
|
||||||
|
role: number
|
||||||
|
receiveAlerts: boolean
|
||||||
|
avatarPath?: string
|
||||||
|
isOwner: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminUsageDay = {
|
export type AdminUsageDay = {
|
||||||
date: string
|
date: string
|
||||||
smsCount: number
|
smsCount: number
|
||||||
@@ -358,13 +403,62 @@ export type AdminPostItem = {
|
|||||||
id: number
|
id: number
|
||||||
deviceId: number
|
deviceId: number
|
||||||
deviceName: string
|
deviceName: string
|
||||||
|
authorUserId: number
|
||||||
authorName: string
|
authorName: string
|
||||||
content: string
|
content: string
|
||||||
postType: number
|
postType: number
|
||||||
imageCount: number
|
imageCount: number
|
||||||
|
hasVoice: boolean
|
||||||
|
voiceDurationSeconds?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminPostImage = {
|
||||||
|
id: number
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminPostDetail = {
|
||||||
|
id: number
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
authorName: string
|
||||||
|
authorFamily: string
|
||||||
|
postType: number
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
images: AdminPostImage[]
|
||||||
|
voiceFilePath?: string
|
||||||
|
voiceDurationSeconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUserGrowthRow = {
|
||||||
|
date: string
|
||||||
|
newUsers: number
|
||||||
|
activeUsers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminDeviceUptimeRow = {
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
ownerName: string
|
||||||
|
totalDays: number
|
||||||
|
onlineDays: number
|
||||||
|
onlinePercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUsageByUserRow = {
|
||||||
|
userId: number
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
mobile: string
|
||||||
|
sms: number
|
||||||
|
voiceCall: number
|
||||||
|
aiQueries: number
|
||||||
|
aiTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminDeviceTestResult = {
|
export type AdminDeviceTestResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
Reference in New Issue
Block a user