✨ 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.Application;
|
||||
using GreenHome.Application.Dtos.Admin;
|
||||
@@ -111,6 +112,44 @@ public sealed class AdminController : ControllerBase
|
||||
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 ====================
|
||||
|
||||
[HttpGet("usage")]
|
||||
@@ -155,6 +194,10 @@ public sealed class AdminController : ControllerBase
|
||||
[HttpGet("posts")]
|
||||
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
||||
[FromQuery] int? deviceId,
|
||||
[FromQuery] int? authorUserId,
|
||||
[FromQuery] Domain.DevicePostType? postType,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -162,6 +205,10 @@ public sealed class AdminController : ControllerBase
|
||||
var filter = new AdminPostFilter
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
AuthorUserId = authorUserId,
|
||||
PostType = postType,
|
||||
From = from,
|
||||
To = to,
|
||||
Page = Math.Max(1, page),
|
||||
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||
};
|
||||
@@ -170,6 +217,13 @@ public sealed class AdminController : ControllerBase
|
||||
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}")]
|
||||
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -196,6 +250,73 @@ public sealed class AdminController : ControllerBase
|
||||
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 ====================
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
|
||||
@@ -113,10 +113,86 @@ public sealed class AdminDeviceDetailDto
|
||||
public string? DevicePhoneNumber { get; set; }
|
||||
public int? UploadIntervalMin { 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; } = [];
|
||||
}
|
||||
|
||||
// ==================== 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 ====================
|
||||
|
||||
public sealed class UsageDayDto
|
||||
@@ -158,6 +234,10 @@ public sealed class AdminAlertItemDto
|
||||
public sealed class AdminPostFilter
|
||||
{
|
||||
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 PageSize { get; set; } = 20;
|
||||
}
|
||||
@@ -167,13 +247,38 @@ public sealed class AdminPostItemDto
|
||||
public int Id { get; set; }
|
||||
public int DeviceId { get; set; }
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
public int AuthorUserId { get; set; }
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public Domain.DevicePostType PostType { get; set; }
|
||||
public int ImageCount { get; set; }
|
||||
public bool HasVoice { get; set; }
|
||||
public int? VoiceDurationSeconds { 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 ====================
|
||||
|
||||
public sealed class AdminDeviceTestResultDto
|
||||
@@ -182,6 +287,37 @@ public sealed class AdminDeviceTestResultDto
|
||||
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 ====================
|
||||
|
||||
public sealed class AdminAuditLogFilter
|
||||
|
||||
@@ -11,11 +11,18 @@ public interface IAdminService
|
||||
Task<UserMutationOutcome> SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
|
||||
Task<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<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter 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<AdminDeviceTestResultDto> TestDeviceSmsAsync(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);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed class AdminService : IAdminService
|
||||
private readonly IVoiceCallService _voiceCallService;
|
||||
private readonly UsageTracker _usageTracker;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IDeviceService _deviceService;
|
||||
private readonly IDeviceSettingsService _deviceSettingsService;
|
||||
private readonly int _onlineWindowMinutes;
|
||||
|
||||
public AdminService(
|
||||
@@ -30,7 +32,9 @@ public sealed class AdminService : IAdminService
|
||||
ISmsService smsService,
|
||||
IVoiceCallService voiceCallService,
|
||||
UsageTracker usageTracker,
|
||||
IWebHostEnvironment environment)
|
||||
IWebHostEnvironment environment,
|
||||
IDeviceService deviceService,
|
||||
IDeviceSettingsService deviceSettingsService)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_mapper = mapper;
|
||||
@@ -38,6 +42,8 @@ public sealed class AdminService : IAdminService
|
||||
_voiceCallService = voiceCallService;
|
||||
_usageTracker = usageTracker;
|
||||
_environment = environment;
|
||||
_deviceService = deviceService;
|
||||
_deviceSettingsService = deviceSettingsService;
|
||||
_onlineWindowMinutes = configuration.GetValue("Admin:OnlineWindowMinutes", 15);
|
||||
}
|
||||
|
||||
@@ -301,10 +307,237 @@ public sealed class AdminService : IAdminService
|
||||
DevicePhoneNumber = settings?.DevicePhoneNumber,
|
||||
UploadIntervalMin = settings?.UploadIntervalMin,
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 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 ====================
|
||||
|
||||
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)
|
||||
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);
|
||||
|
||||
@@ -422,10 +663,13 @@ public sealed class AdminService : IAdminService
|
||||
Id = p.Id,
|
||||
DeviceId = p.DeviceId,
|
||||
DeviceName = p.Device.DeviceName,
|
||||
AuthorUserId = p.AuthorUserId,
|
||||
AuthorName = p.AuthorUser.Name + " " + p.AuthorUser.Family,
|
||||
Content = p.Content,
|
||||
PostType = p.PostType,
|
||||
ImageCount = p.Images.Count,
|
||||
HasVoice = !string.IsNullOrWhiteSpace(p.VoiceFilePath),
|
||||
VoiceDurationSeconds = p.VoiceDurationSeconds,
|
||||
CreatedAt = p.CreatedAt
|
||||
}).ToList(),
|
||||
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)
|
||||
{
|
||||
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 ====================
|
||||
|
||||
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 AdminPostsPage from '@/app/admin/posts/page'
|
||||
import AdminUsagePage from '@/app/admin/usage/page'
|
||||
import AdminReportsPage from '@/app/admin/reports/page'
|
||||
import AdminAuditPage from '@/app/admin/audit/page'
|
||||
|
||||
function DeviceRedirect() {
|
||||
@@ -52,6 +53,7 @@ export default function App() {
|
||||
<Route path="alerts" element={<AdminAlertsPage />} />
|
||||
<Route path="posts" element={<AdminPostsPage />} />
|
||||
<Route path="usage" element={<AdminUsagePage />} />
|
||||
<Route path="reports" element={<AdminReportsPage />} />
|
||||
<Route path="audit" element={<AdminAuditPage />} />
|
||||
</Route>
|
||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { Link } from '@/lib/router'
|
||||
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText } from 'lucide-react'
|
||||
import { Link, useRouter } from '@/lib/router'
|
||||
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText, LogOut, FileBarChart } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'داشبورد', icon: LayoutDashboard, exact: true },
|
||||
@@ -12,11 +15,34 @@ const NAV_ITEMS = [
|
||||
{ href: '/admin/alerts', label: 'اعلانها', icon: Bell, exact: false },
|
||||
{ href: '/admin/posts', label: 'پستها', icon: MessageSquare, exact: false },
|
||||
{ href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false },
|
||||
{ href: '/admin/reports', label: 'گزارشها', icon: FileBarChart, 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() {
|
||||
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]) =>
|
||||
item.exact ? pathname === item.href : pathname.startsWith(item.href)
|
||||
@@ -46,34 +72,44 @@ export default function AdminLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] flex flex-col md:flex-row">
|
||||
{/* Desktop sidebar (right side in RTL) */}
|
||||
<aside className="hidden md:flex flex-col w-60 shrink-0 border-l border-gray-200 bg-white p-4 sticky top-0 h-screen">
|
||||
<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">
|
||||
<h1 className="text-base font-bold text-gray-900">پنل مدیریت</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">گ린هوم</p>
|
||||
</div>
|
||||
<nav className="flex-1 flex flex-col gap-1">{navLinks()}</nav>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
بازگشت به اپلیکیشن
|
||||
</Link>
|
||||
</aside>
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<header className="md:hidden sticky top-0 z-20 bg-white border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<h1 className="text-sm font-bold text-gray-900">پنل مدیریت</h1>
|
||||
<Link href="/" className="text-xs font-medium text-gray-500 flex items-center gap-1">
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
خروج
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex gap-1 px-3 pb-2 overflow-x-auto">
|
||||
{navLinks()}
|
||||
</nav>
|
||||
</header>
|
||||
{/* Sticky header (all breakpoints, merges the old mobile header) */}
|
||||
<div className="sticky top-0 z-20 w-full md:flex-1">
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="h-14 flex items-center justify-between px-4 md:px-6">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{fullName}</span>
|
||||
<Badge variant="success" size="sm">ادمین</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 */}
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState, type ChangeEvent } from '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 { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { Pagination } from '@/components/navigation/Pagination'
|
||||
import { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { SearchInput } from '@/components/forms/SearchInput'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -42,6 +43,8 @@ export default function AdminAlertsPage() {
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = 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) => {
|
||||
setLoading(true)
|
||||
@@ -72,6 +75,15 @@ export default function AdminAlertsPage() {
|
||||
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 = () => {
|
||||
setFrom(daysAgoIso(6))
|
||||
setTo(new Date().toISOString().slice(0, 10))
|
||||
@@ -82,6 +94,7 @@ export default function AdminAlertsPage() {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
setDeviceId('')
|
||||
setDeviceSearch('')
|
||||
setOnlyErrors(false)
|
||||
setPage(1)
|
||||
}
|
||||
@@ -118,14 +131,31 @@ export default function AdminAlertsPage() {
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mr-auto">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={deviceId}
|
||||
onChange={(e) => { setDeviceId(e.target.value); setPage(1) }}
|
||||
placeholder="شناسه دستگاه"
|
||||
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
||||
/>
|
||||
<div className="relative w-52">
|
||||
<SearchInput
|
||||
value={deviceSearch}
|
||||
onValueChange={(v) => { setDeviceSearch(v); searchDevices(v) }}
|
||||
onSubmit={(v) => searchDevices(v ?? deviceSearch)}
|
||||
onClear={() => { setDeviceId(''); setDeviceResults([]); setPage(1) }}
|
||||
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
|
||||
type="date"
|
||||
value={from}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from '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 { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { Pagination } from '@/components/navigation/Pagination'
|
||||
import { Badge } from '@/components/common/Badge'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { SearchInput } from '@/components/forms/SearchInput'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -16,12 +17,20 @@ export default function AdminAuditPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
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)
|
||||
setError('')
|
||||
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)
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در دریافت لاگ مدیریت')
|
||||
@@ -31,8 +40,25 @@ export default function AdminAuditPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs(page)
|
||||
}, [page, fetchLogs])
|
||||
fetchLogs(page, adminId)
|
||||
}, [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 showPagination = totalPages > 1
|
||||
@@ -48,6 +74,54 @@ export default function AdminAuditPage() {
|
||||
)}
|
||||
</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>}
|
||||
|
||||
{loading ? (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall } from 'lucide-react'
|
||||
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult } from '@/lib/api'
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall, Plus, Pencil, Users, Bell, BellOff } from 'lucide-react'
|
||||
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 { formatPersianDateFromIso } from '@/lib/date/persian-date'
|
||||
import { getDeviceMemberRoleLabel } from '@/features/team/constants'
|
||||
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 { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
@@ -22,6 +26,10 @@ export default function AdminDevicesPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
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 [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) => {
|
||||
setMembers([])
|
||||
setMembersLoading(true)
|
||||
try {
|
||||
const d = await api.adminDevice(id)
|
||||
setDetail(d)
|
||||
setTestSms({ busy: false, result: null })
|
||||
setTestVoice({ busy: false, result: null })
|
||||
try {
|
||||
setMembers(await api.adminDeviceMembers(id))
|
||||
} catch {
|
||||
setMembers([])
|
||||
}
|
||||
} catch (e) {
|
||||
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) => {
|
||||
setter({ busy: true, result: null })
|
||||
try {
|
||||
@@ -77,7 +99,12 @@ export default function AdminDevicesPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-bold text-gray-900">دستگاهها</h2>
|
||||
<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 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{toPersianDigits(result.totalCount)} دستگاه
|
||||
@@ -207,6 +234,12 @@ export default function AdminDevicesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" size="sm" icon={Pencil} onClick={() => setEditOpen(true)}>
|
||||
ویرایش مشخصات
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 mb-2">تست اعلان</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -237,6 +270,60 @@ export default function AdminDevicesPage() {
|
||||
)}
|
||||
</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>
|
||||
<h4 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||
<Thermometer className="w-4 h-4 text-gray-400" />
|
||||
@@ -272,6 +359,261 @@ export default function AdminDevicesPage() {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2 } from 'lucide-react'
|
||||
import { api, PagedResult, AdminPostItem } from '@/lib/api'
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { MessageSquare, Image as ImageIcon, Mic, Camera, Trash2, Eye } from 'lucide-react'
|
||||
import { api, PagedResult, AdminPostItem, AdminPostDetail } from '@/lib/api'
|
||||
import { resolveAssetUrl } from '@/lib/api/assets'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
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 { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
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 type { AdminDeviceListItem, AdminUserListItem } from '@/lib/api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -21,6 +27,25 @@ const POST_TYPE_META: Record<number, { label: string; icon: typeof MessageSquare
|
||||
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() {
|
||||
const [result, setResult] = useState<PagedResult<AdminPostItem> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -29,11 +54,35 @@ export default function AdminPostsPage() {
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
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)
|
||||
setError('')
|
||||
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)
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در دریافت پستها')
|
||||
@@ -43,27 +92,74 @@ export default function AdminPostsPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts(page)
|
||||
}, [page, fetchPosts])
|
||||
fetchPosts(page, postTypeTab, from, to, deviceId, authorUserId)
|
||||
}, [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({
|
||||
title: 'حذف پست',
|
||||
message: `این پست بهطور کامل حذف میشود (با تصاویر آن). ادامه میدهید؟`,
|
||||
message: 'این پست بهطور کامل حذف میشود (با تصاویر آن). ادامه میدهید؟',
|
||||
confirmText: 'حذف',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setBusyId(post.id)
|
||||
setBusyId(postId)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminDeletePost(post.id)
|
||||
await api.adminDeletePost(postId)
|
||||
setDetail(null)
|
||||
// if the current page becomes empty, step back a page
|
||||
if (result && result.items.length === 1 && page > 1) {
|
||||
setPage(page - 1)
|
||||
} else {
|
||||
fetchPosts(page)
|
||||
fetchPosts(page, postTypeTab, from, to, deviceId, authorUserId)
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message || 'خطا در حذف پست')
|
||||
@@ -86,6 +182,80 @@ export default function AdminPostsPage() {
|
||||
)}
|
||||
</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>}
|
||||
|
||||
{loading ? (
|
||||
@@ -124,8 +294,15 @@ export default function AdminPostsPage() {
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="default" size="sm" icon={TypeIcon}>{meta.label}</Badge>
|
||||
{post.imageCount > 0 && (
|
||||
<span className="text-xs text-gray-400 mr-1">
|
||||
{toPersianDigits(post.imageCount)} تصویر
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400 mr-2">
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
@@ -136,10 +313,15 @@ export default function AdminPostsPage() {
|
||||
{formatPersianDateFromIso(post.createdAt)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<Button variant="ghost" size="sm" icon={Trash2} disabled={busyId === post.id}
|
||||
onClick={() => handleDelete(post)} tooltip="حذف پست">
|
||||
حذف
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" icon={Eye} onClick={() => openDetail(post)} tooltip="جزئیات پست">
|
||||
جزئیات
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" icon={Trash2} disabled={busyId === post.id}
|
||||
onClick={() => handleDelete(post.id)} tooltip="حذف پست">
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -155,10 +337,83 @@ export default function AdminPostsPage() {
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
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 />
|
||||
</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,
|
||||
AdminDeviceListItem,
|
||||
AdminDeviceDetail,
|
||||
AdminDeviceCreateRequest,
|
||||
AdminDeviceSettingsRequest,
|
||||
AdminDeviceMember,
|
||||
AdminUsageDay,
|
||||
AdminAlertItem,
|
||||
AdminPostItem,
|
||||
AdminPostDetail,
|
||||
AdminDeviceTestResult,
|
||||
AdminAuditLogItem,
|
||||
AdminUserGrowthRow,
|
||||
AdminDeviceUptimeRow,
|
||||
AdminUsageByUserRow,
|
||||
} from './types'
|
||||
|
||||
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()}`)
|
||||
},
|
||||
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 }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q.from) params.set('from', q.from)
|
||||
@@ -253,13 +266,18 @@ export const api = {
|
||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
return http<PagedResult<AdminAlertItem>>(`${API_BASE}/api/admin/alerts?${params.toString()}`)
|
||||
},
|
||||
adminPosts: (q: { deviceId?: number; page?: number; pageSize?: number }) => {
|
||||
adminPosts: (q: { deviceId?: number; authorUserId?: number; postType?: number; from?: string; to?: string; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
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.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
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) =>
|
||||
http<void>(`${API_BASE}/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
adminTestSms: (deviceId: number) =>
|
||||
@@ -273,4 +291,46 @@ export const api = {
|
||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
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
|
||||
uploadIntervalMin?: number
|
||||
relayCount?: number
|
||||
neshanLocation?: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
minimumSmsIntervalMinutes?: number
|
||||
minimumCallIntervalMinutes?: number
|
||||
areaSquareMeters?: number
|
||||
simCardType?: number
|
||||
smsRecievers?: string
|
||||
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 = {
|
||||
date: string
|
||||
smsCount: number
|
||||
@@ -358,13 +403,62 @@ export type AdminPostItem = {
|
||||
id: number
|
||||
deviceId: number
|
||||
deviceName: string
|
||||
authorUserId: number
|
||||
authorName: string
|
||||
content: string
|
||||
postType: number
|
||||
imageCount: number
|
||||
hasVoice: boolean
|
||||
voiceDurationSeconds?: number
|
||||
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 = {
|
||||
success: boolean
|
||||
message: string
|
||||
|
||||
Reference in New Issue
Block a user