✨ 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:
2026-09-25 18:39:52 +03:30
parent 6abb5aae15
commit 69c0381530
13 changed files with 1847 additions and 62 deletions

View File

@@ -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")]