From b5575903032f4196131a3dd02d6010d08baf373b Mon Sep 17 00:00:00 2001 From: alireza Date: Fri, 25 Sep 2026 19:04:57 +0330 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20improve=20search=20a?= =?UTF-8?q?nd=20owner=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - debounce admin searches to reduce API calls while typing - apply 300ms debounce to alerts, audit, devices, posts - allow clearing selected owner in device creation form 🐛 fix(admin): correct admin data and filters - use invariant culture for CSV numeric report values - align default report end date with UTC day boundary - prevent duplicate owner row in device member list - make user growth date filtering EF translatable - clear report preset when user picks a custom date range - send zero-valued admin filter parameters in API client --- .../Controllers/AdminController.cs | 10 ++- .../Services/Admin/AdminService.cs | 89 ++++++++++++------- .../src/app/admin/alerts/page.tsx | 12 ++- .../src/app/admin/audit/page.tsx | 10 ++- .../src/app/admin/devices/page.tsx | 24 ++++- .../src/app/admin/posts/page.tsx | 18 +++- .../src/app/admin/reports/page.tsx | 4 +- src/GreenHome.Web/src/lib/api/client.ts | 6 +- 8 files changed, 122 insertions(+), 51 deletions(-) diff --git a/src/GreenHome.Api/Controllers/AdminController.cs b/src/GreenHome.Api/Controllers/AdminController.cs index 02e4f1d..500dabf 100644 --- a/src/GreenHome.Api/Controllers/AdminController.cs +++ b/src/GreenHome.Api/Controllers/AdminController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using GreenHome.Api.Filters; using GreenHome.Application; @@ -261,7 +262,7 @@ public sealed class AdminController : ControllerBase if (csv) return ToCsvFile("report-users", new[] { "تاریخ", "کاربران جدید", "کاربران فعال" }, - rows.Select(r => new[] { r.Date.ToString("yyyy-MM-dd"), r.NewUsers.ToString(), r.ActiveUsers.ToString() }), t); + rows.Select(r => new[] { r.Date.ToString("yyyy-MM-dd"), r.NewUsers.ToString(CultureInfo.InvariantCulture), r.ActiveUsers.ToString(CultureInfo.InvariantCulture) }), t); return Ok(rows); } @@ -274,7 +275,7 @@ public sealed class AdminController : ControllerBase 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); + rows.Select(r => new[] { r.DeviceName, r.OwnerName, r.TotalDays.ToString(CultureInfo.InvariantCulture), r.OnlineDays.ToString(CultureInfo.InvariantCulture), r.OnlinePercent.ToString(CultureInfo.InvariantCulture) }), t); return Ok(rows); } @@ -287,13 +288,14 @@ public sealed class AdminController : ControllerBase 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); + rows.Select(r => new[] { r.Name, r.Family, r.Mobile, r.Sms.ToString(CultureInfo.InvariantCulture), r.VoiceCall.ToString(CultureInfo.InvariantCulture), r.AiQueries.ToString(CultureInfo.InvariantCulture), r.AiTokens.ToString(CultureInfo.InvariantCulture) }), t); return Ok(rows); } + // ponytail: day boundary is UTC (whole system is UTC); if user timezone matters later, make report range timezone-aware. private static (DateTime From, DateTime To) ResolveReportRange(DateTime? from, DateTime? to) { - var t = (to ?? DateTime.Today).Date; + var t = (to ?? DateTime.UtcNow).Date; var f = (from ?? t.AddDays(-6)).Date; return (f, t); } diff --git a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs index f087664..4686efc 100644 --- a/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs +++ b/src/GreenHome.Infrastructure/Services/Admin/AdminService.cs @@ -334,6 +334,9 @@ public sealed class AdminService : IAdminService if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName, cancellationToken)) return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.ConflictDuplicateName, Message = $"دستگاهی با نام «{deviceName}» از قبل وجود دارد" }; + // ponytail: no transaction around AddDevice + settings — services save independently. + // If settings creation fails after the device exists, a device without settings is left behind; + // UpdateDeviceSettingsAsync self-heals (creates settings when they are missing). var deviceId = await _deviceService.AddDeviceAsync(new DeviceDto { DeviceName = deviceName, @@ -504,24 +507,43 @@ public sealed class AdminService : IAdminService .ThenBy(du => du.UserId) .ToListAsync(cancellationToken); - var result = new List(members.Count + 1); + var ownerUserId = device.User.Id; + var ownerMember = members.FirstOrDefault(du => du.UserId == ownerUserId); + var membersWithoutOwner = members.Where(du => du.UserId != ownerUserId).ToList(); - // 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 - }); + var result = new List(membersWithoutOwner.Count + 1); - result.AddRange(members.Select(du => new AdminDeviceMemberDto + // owner row first; if the owner is also in DeviceUsers, mark that row IsOwner + // instead of emitting a duplicate row (every user appears exactly once). + result.Add(ownerMember != null + ? new AdminDeviceMemberDto + { + UserId = ownerMember.UserId, + Name = ownerMember.User.Name, + Family = ownerMember.User.Family, + Mobile = ownerMember.User.Mobile, + MemberName = string.IsNullOrWhiteSpace(ownerMember.Name) ? ownerMember.User.Name : ownerMember.Name, + MemberFamily = string.IsNullOrWhiteSpace(ownerMember.Family) ? ownerMember.User.Family : ownerMember.Family, + Role = ownerMember.Role, + ReceiveAlerts = ownerMember.ReceiveAlerts, + AvatarPath = ownerMember.AvatarPath, + IsOwner = true + } + : new AdminDeviceMemberDto + { + UserId = ownerUserId, + 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(membersWithoutOwner.Select(du => new AdminDeviceMemberDto { UserId = du.UserId, Name = du.User.Name, @@ -837,31 +859,30 @@ public sealed class AdminService : IAdminService public async Task> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken) { - var fromDate = DateOnly.FromDateTime(from.Date); - var toDate = DateOnly.FromDateTime(to.Date); + // ponytail: DateTime.Date is not EF-translatable, so the day filter is a range + // comparison and the (bounded, in-range) timestamps are grouped in memory. + var fromDate = from.Date; + var toDateExclusive = to.Date.AddDays(1); - 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 newDates = await _dbContext.Users.AsNoTracking() + .Where(u => u.CreatedAt >= fromDate && u.CreatedAt < toDateExclusive) + .Select(u => u.CreatedAt.Date) + .ToListAsync(cancellationToken); + var newUsers = newDates.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count()); - 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 activeDates = await _dbContext.Users.AsNoTracking() + .Where(u => u.LastLoginAt != null && u.LastLoginAt >= fromDate && u.LastLoginAt < toDateExclusive) + .Select(u => u.LastLoginAt!.Value.Date) + .ToListAsync(cancellationToken); + var activeUsers = activeDates.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count()); var rows = new List(); - for (var day = fromDate; day <= toDate; day = day.AddDays(1)) + for (var day = fromDate; day < toDateExclusive; day = day.AddDays(1)) { + var key = DateOnly.FromDateTime(day); rows.Add(new AdminUserGrowthRowDto { - Date = day, + Date = key, NewUsers = newUsers.GetValueOrDefault(day), ActiveUsers = activeUsers.GetValueOrDefault(day) }); diff --git a/src/GreenHome.Web/src/app/admin/alerts/page.tsx b/src/GreenHome.Web/src/app/admin/alerts/page.tsx index ae2e7c4..00fa93f 100644 --- a/src/GreenHome.Web/src/app/admin/alerts/page.tsx +++ b/src/GreenHome.Web/src/app/admin/alerts/page.tsx @@ -10,6 +10,7 @@ import { Badge } from '@/components/common/Badge' import { Button } from '@/components/common/Button' import { EmptyState } from '@/components/common/EmptyState' import { SearchInput } from '@/components/forms/SearchInput' +import { useDebounce } from '@/hooks/useDebounce' const PAGE_SIZE = 20 @@ -84,6 +85,13 @@ export default function AdminAlertsPage() { } } + const debouncedDeviceSearch = useDebounce(deviceSearch, 300) + + useEffect(() => { + if (debouncedDeviceSearch) searchDevices(debouncedDeviceSearch) + else setDeviceResults([]) + }, [debouncedDeviceSearch]) + const applyLast7Days = () => { setFrom(daysAgoIso(6)) setTo(new Date().toISOString().slice(0, 10)) @@ -134,9 +142,9 @@ export default function AdminAlertsPage() {
{ setDeviceSearch(v); searchDevices(v) }} + onValueChange={(v) => setDeviceSearch(v)} onSubmit={(v) => searchDevices(v ?? deviceSearch)} - onClear={() => { setDeviceId(''); setDeviceResults([]); setPage(1) }} + onClear={() => { setDeviceId(''); setDeviceSearch(''); setDeviceResults([]); setPage(1) }} placeholder="فیلتر دستگاه..." showSearchButton={false} /> diff --git a/src/GreenHome.Web/src/app/admin/audit/page.tsx b/src/GreenHome.Web/src/app/admin/audit/page.tsx index d1ae811..da442e4 100644 --- a/src/GreenHome.Web/src/app/admin/audit/page.tsx +++ b/src/GreenHome.Web/src/app/admin/audit/page.tsx @@ -9,6 +9,7 @@ import { Pagination } from '@/components/navigation/Pagination' import { Badge } from '@/components/common/Badge' import { EmptyState } from '@/components/common/EmptyState' import { SearchInput } from '@/components/forms/SearchInput' +import { useDebounce } from '@/hooks/useDebounce' const PAGE_SIZE = 20 @@ -52,6 +53,13 @@ export default function AdminAuditPage() { } } + const debouncedAdminSearch = useDebounce(adminSearch, 300) + + useEffect(() => { + if (debouncedAdminSearch) searchAdmins(debouncedAdminSearch) + else setAdminResults([]) + }, [debouncedAdminSearch]) + const clearAdminFilter = () => { setAdminId('') setAdminSearch('') @@ -92,7 +100,7 @@ export default function AdminAuditPage() { <> { setAdminSearch(v); searchAdmins(v) }} + onValueChange={(v) => setAdminSearch(v)} onSubmit={(v) => searchAdmins(v ?? adminSearch)} placeholder="فیلتر ادمین..." showSearchButton={false} diff --git a/src/GreenHome.Web/src/app/admin/devices/page.tsx b/src/GreenHome.Web/src/app/admin/devices/page.tsx index 70ff7af..93723a8 100644 --- a/src/GreenHome.Web/src/app/admin/devices/page.tsx +++ b/src/GreenHome.Web/src/app/admin/devices/page.tsx @@ -1,7 +1,7 @@ 'use client' import { FormEvent, useCallback, useEffect, useState } from 'react' -import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall, Plus, Pencil, Users, Bell, BellOff } from 'lucide-react' +import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall, Plus, Pencil, Users, Bell, BellOff, X } 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' @@ -11,6 +11,7 @@ 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 { useDebounce } from '@/hooks/useDebounce' import { Badge } from '@/components/common/Badge' import { Button } from '@/components/common/Button' import { Modal } from '@/components/common/Modal' @@ -402,6 +403,13 @@ function CreateDeviceForm({ onCreated }: { onCreated: () => void }) { } } + const debouncedOwnerSearch = useDebounce(ownerSearch, 300) + + useEffect(() => { + if (debouncedOwnerSearch) searchOwners(debouncedOwnerSearch) + else setOwnerResults([]) + }, [debouncedOwnerSearch]) + const submit = async (e: FormEvent) => { e.preventDefault() if (!owner) { setMessage({ kind: 'error', text: 'لطفاً مالک دستگاه را انتخاب کنید.' }); return } @@ -441,13 +449,23 @@ function CreateDeviceForm({ onCreated }: { onCreated: () => void }) { {owner ? (
{owner.name} {owner.family} - {toPersianDigits(owner.mobile)} +
+ {toPersianDigits(owner.mobile)} + +
) : ( <> { setOwnerSearch(v); if (v !== undefined) searchOwners(v) }} + onValueChange={(v) => setOwnerSearch(v)} onSubmit={(v) => { const s = v ?? ownerSearch; setOwnerSearch(s); searchOwners(s) }} placeholder="جستجو بر اساس نام یا موبایل..." /> diff --git a/src/GreenHome.Web/src/app/admin/posts/page.tsx b/src/GreenHome.Web/src/app/admin/posts/page.tsx index 4c41288..ba60e9d 100644 --- a/src/GreenHome.Web/src/app/admin/posts/page.tsx +++ b/src/GreenHome.Web/src/app/admin/posts/page.tsx @@ -14,6 +14,7 @@ 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 { useDebounce } from '@/hooks/useDebounce' import { useConfirmDialog } from '@/components/utils/ConfirmDialog' import type { AdminDeviceListItem, AdminUserListItem } from '@/lib/api' @@ -129,6 +130,19 @@ export default function AdminPostsPage() { } } + const debouncedDeviceSearch = useDebounce(deviceSearch, 300) + const debouncedAuthorSearch = useDebounce(authorSearch, 300) + + useEffect(() => { + if (debouncedDeviceSearch) searchDevices(debouncedDeviceSearch) + else setDeviceResults([]) + }, [debouncedDeviceSearch]) + + useEffect(() => { + if (debouncedAuthorSearch) searchAuthors(debouncedAuthorSearch) + else setAuthorResults([]) + }, [debouncedAuthorSearch]) + const openDetail = async (post: AdminPostItem) => { setDetailLoading(true) setDetail(null) @@ -195,7 +209,7 @@ export default function AdminPostsPage() {
{ setDeviceSearch(v); if (v !== undefined) searchDevices(v) }} + onValueChange={(v) => setDeviceSearch(v)} onSubmit={(v) => { const s = v ?? deviceSearch; setDeviceSearch(s); searchDevices(s) }} placeholder="فیلتر دستگاه..." className="text-xs" @@ -219,7 +233,7 @@ export default function AdminPostsPage() {
{ setAuthorSearch(v); if (v !== undefined) searchAuthors(v) }} + onValueChange={(v) => setAuthorSearch(v)} onSubmit={(v) => { const s = v ?? authorSearch; setAuthorSearch(s); searchAuthors(s) }} placeholder="فیلتر نویسنده..." className="text-xs" diff --git a/src/GreenHome.Web/src/app/admin/reports/page.tsx b/src/GreenHome.Web/src/app/admin/reports/page.tsx index fdb3cf1..7c5b4b4 100644 --- a/src/GreenHome.Web/src/app/admin/reports/page.tsx +++ b/src/GreenHome.Web/src/app/admin/reports/page.tsx @@ -28,7 +28,7 @@ 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 [preset, setPreset] = useState(7) const [loading, setLoading] = useState(true) const [csvBusy, setCsvBusy] = useState(false) @@ -64,7 +64,7 @@ export default function AdminReportsPage() { const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent) => { setter(e.target.value) - setPreset(0 as unknown as RangePreset) + setPreset(null) } const downloadCsv = async () => { diff --git a/src/GreenHome.Web/src/lib/api/client.ts b/src/GreenHome.Web/src/lib/api/client.ts index 62d6cf8..f484c63 100644 --- a/src/GreenHome.Web/src/lib/api/client.ts +++ b/src/GreenHome.Web/src/lib/api/client.ts @@ -259,7 +259,7 @@ export const api = { adminAlerts: (q: { deviceId?: number; status?: number; from?: string; to?: string; page?: number; pageSize?: number }) => { const params = new URLSearchParams() if (q.deviceId) params.set('deviceId', String(q.deviceId)) - if (q.status) params.set('status', String(q.status)) + if (q.status != null) params.set('status', String(q.status)) if (q.from) params.set('from', q.from) if (q.to) params.set('to', q.to) if (q.page) params.set('page', String(q.page)) @@ -269,7 +269,7 @@ export const api = { 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.authorUserId != null) 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) @@ -286,7 +286,7 @@ export const api = { http(`${API_BASE}/api/admin/devices/${deviceId}/test-voice`, { method: 'POST' }), adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => { const params = new URLSearchParams() - if (q.adminId) params.set('adminId', String(q.adminId)) + if (q.adminId != null) params.set('adminId', String(q.adminId)) if (q.page) params.set('page', String(q.page)) if (q.pageSize) params.set('pageSize', String(q.pageSize)) return http>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)