From 69c0381530c7ba4a173550833f6bae7a4520000e Mon Sep 17 00:00:00 2001 From: alireza Date: Fri, 25 Sep 2026 18:39:52 +0330 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20add=20admin=20device?= =?UTF-8?q?,=20post,=20and=20report=20features=20-=20add=20admin=20endpoin?= =?UTF-8?q?ts=20to=20create=20devices=20and=20update=20device=20settings?= =?UTF-8?q?=20-=20expose=20device=20members=20and=20post=20detail=20for=20?= =?UTF-8?q?admin=20management=20-=20add=20post=20filters=20by=20device,=20?= =?UTF-8?q?author,=20type,=20and=20date=20range=20-=20add=20user=20growth,?= =?UTF-8?q?=20device=20uptime,=20and=20usage=20reports=20with=20CSV=20expo?= =?UTF-8?q?rt=20-=20improve=20admin=20layout=20with=20sticky=20header,=20u?= =?UTF-8?q?ser=20info,=20and=20logout=20-=20add=20device=20and=20author=20?= =?UTF-8?q?search=20filters=20across=20admin=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AdminController.cs | 121 ++++++ .../Dtos/Admin/AdminDtos.cs | 136 ++++++ .../Interfaces/IAdminService.cs | 7 + .../Services/Admin/AdminService.cs | 390 +++++++++++++++++- src/GreenHome.Web/src/App.tsx | 2 + .../src/app/admin/AdminLayout.tsx | 82 ++-- .../src/app/admin/alerts/page.tsx | 48 ++- .../src/app/admin/audit/page.tsx | 84 +++- .../src/app/admin/devices/page.tsx | 350 +++++++++++++++- .../src/app/admin/posts/page.tsx | 293 ++++++++++++- .../src/app/admin/reports/page.tsx | 240 +++++++++++ src/GreenHome.Web/src/lib/api/client.ts | 62 ++- src/GreenHome.Web/src/lib/api/types.ts | 94 +++++ 13 files changed, 1847 insertions(+), 62 deletions(-) create mode 100644 src/GreenHome.Web/src/app/admin/reports/page.tsx diff --git a/src/GreenHome.Api/Controllers/AdminController.cs b/src/GreenHome.Api/Controllers/AdminController.cs index ef99f8b..02e4f1d 100644 --- a/src/GreenHome.Api/Controllers/AdminController.cs +++ b/src/GreenHome.Api/Controllers/AdminController.cs @@ -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 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 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> 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>> 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>> 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> 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 DeletePost(int id, CancellationToken cancellationToken) { @@ -196,6 +250,73 @@ public sealed class AdminController : ControllerBase return Ok(result); } + // ==================== Reports ==================== + + [HttpGet("reports/users")] + public async Task 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 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 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 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")] diff --git a/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs b/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs index a048060..1fe9bc8 100644 --- a/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs +++ b/src/GreenHome.Application/Dtos/Admin/AdminDtos.cs @@ -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 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; + /// پیام فارسی برای خطاهای Validation + 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; +} + +/// +/// Success = !NotFound && Message == null +/// +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 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 diff --git a/src/GreenHome.Application/Interfaces/IAdminService.cs b/src/GreenHome.Application/Interfaces/IAdminService.cs index 742eb6d..f4f99c9 100644 --- a/src/GreenHome.Application/Interfaces/IAdminService.cs +++ b/src/GreenHome.Application/Interfaces/IAdminService.cs @@ -11,11 +11,18 @@ public interface IAdminService Task SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken); Task> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken); Task GetDeviceAsync(int id, CancellationToken cancellationToken); + Task CreateDeviceAsync(AdminDeviceCreateRequest request, int currentAdminId, CancellationToken cancellationToken); + Task UpdateDeviceSettingsAsync(int deviceId, AdminDeviceSettingsUpdateRequest request, int currentAdminId, CancellationToken cancellationToken); + Task?> GetDeviceMembersAsync(int deviceId, CancellationToken cancellationToken); Task> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken); Task> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken); Task> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken); + Task GetPostAsync(int id, CancellationToken cancellationToken); Task DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken); Task TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken); Task TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken); + Task> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken); + Task> GetDeviceUptimeReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken); + Task> GetUsageByUserReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken); Task> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken); } diff --git a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs index e4a2012..f087664 100644 --- a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs +++ b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs @@ -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>(lastTelemetry) }; } + // ==================== Devices: create / settings / members ==================== + + public async Task 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() + }).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 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() + }).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?> 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(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> 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 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 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> 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(); + 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> 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> 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> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken) diff --git a/src/GreenHome.Web/src/App.tsx b/src/GreenHome.Web/src/App.tsx index b4ba7e2..a74f7b1 100644 --- a/src/GreenHome.Web/src/App.tsx +++ b/src/GreenHome.Web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx index 01b6c30..992b8fc 100644 --- a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx +++ b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx @@ -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(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 (
{/* Desktop sidebar (right side in RTL) */} -
) diff --git a/src/GreenHome.Web/src/app/admin/reports/page.tsx b/src/GreenHome.Web/src/app/admin/reports/page.tsx new file mode 100644 index 0000000..fdb3cf1 --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/reports/page.tsx @@ -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('users') + const [from, setFrom] = useState(daysAgo(6)) + const [to, setTo] = useState(new Date().toISOString().slice(0, 10)) + const [preset, setPreset] = useState(7) + + const [loading, setLoading] = useState(true) + const [csvBusy, setCsvBusy] = useState(false) + const [error, setError] = useState('') + const [usersRows, setUsersRows] = useState([]) + const [uptimeRows, setUptimeRows] = useState([]) + const [usageRows, setUsageRows] = useState([]) + + 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) => { + 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 ( +
+
+

گزارش‌ها

+ +
+ + { setTab(v); setPreset(7); setFrom(daysAgo(6)); setTo(new Date().toISOString().slice(0, 10)) }} /> + +
+ {([7, 30, 90] as RangePreset[]).map((n) => ( + + ))} +
+ + تا + +
+
+ +
+ {summaryCards.map((c) => ( + + + + +
+

{c.label}

+

{toPersianDigits(c.value)}

+
+
+ ))} +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : tab === 'users' ? ( + usersRows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + {[...usersRows].reverse().map((r) => ( + + + + + + ))} + +
تاریخکاربران جدیدکاربران فعال (ورود)
{r.date}{toPersianDigits(r.newUsers)}{toPersianDigits(r.activeUsers)}
+
+ ) + ) : tab === 'uptime' ? ( + uptimeRows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {uptimeRows.map((r) => ( + + + + + + + + ))} + +
دستگاهمالککل روزهاروزهای آنلایندرصد آنلاین
{r.deviceName}{r.ownerName}{toPersianDigits(r.totalDays)}{toPersianDigits(r.onlineDays)} + = 70 ? 'text-green-700' : r.onlinePercent >= 40 ? 'text-yellow-700' : 'text-red-700'}> + {toPersianDigits(r.onlinePercent)}٪ + +
+
+ ) + ) : ( + usageRows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {usageRows.map((r) => ( + + + + + + + + + ))} + +
کاربرموبایلپیامکتماس صوتیدرخواست هوش مصنوعیتوکن هوش مصنوعی
{r.name} {r.family}{toPersianDigits(r.mobile)}{toPersianDigits(r.sms)}{toPersianDigits(r.voiceCall)}{toPersianDigits(r.aiQueries)}{toPersianDigits(r.aiTokens)}
+
+ ) + )} +
+ ) +} diff --git a/src/GreenHome.Web/src/lib/api/client.ts b/src/GreenHome.Web/src/lib/api/client.ts index 76cf200..62d6cf8 100644 --- a/src/GreenHome.Web/src/lib/api/client.ts +++ b/src/GreenHome.Web/src/lib/api/client.ts @@ -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>(`${API_BASE}/api/admin/devices?${params.toString()}`) }, adminDevice: (id: number) => http(`${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(`${API_BASE}/api/admin/devices/${id}/settings`), + adminUpdateDeviceSettings: (id: number, req: AdminDeviceSettingsRequest) => + http(`${API_BASE}/api/admin/devices/${id}/settings`, { method: 'PUT', body: JSON.stringify(req) }), + adminDeviceMembers: (id: number) => http(`${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>(`${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>(`${API_BASE}/api/admin/posts?${params.toString()}`) }, + adminPost: (id: number) => http(`${API_BASE}/api/admin/posts/${id}`), adminDeletePost: (id: number) => http(`${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>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`) }, + adminReportUsers: (q: { from?: string; to?: string }) => + http(`${API_BASE}/api/admin/reports/users${ReportParams(q)}`), + adminReportDeviceUptime: (q: { from?: string; to?: string }) => + http(`${API_BASE}/api/admin/reports/devices-uptime${ReportParams(q)}`), + adminReportUsageByUser: (q: { from?: string; to?: string }) => + http(`${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 { + const token = getAuthToken() + return token ? { Authorization: `Bearer ${token}` } : {} } diff --git a/src/GreenHome.Web/src/lib/api/types.ts b/src/GreenHome.Web/src/lib/api/types.ts index 78ef19b..95e2e85 100644 --- a/src/GreenHome.Web/src/lib/api/types.ts +++ b/src/GreenHome.Web/src/lib/api/types.ts @@ -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