✨ feat(admin): improve search and owner selection
- 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
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,12 +507,31 @@ public sealed class AdminService : IAdminService
|
||||
.ThenBy(du => du.UserId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var result = new List<AdminDeviceMemberDto>(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
|
||||
var result = new List<AdminDeviceMemberDto>(membersWithoutOwner.Count + 1);
|
||||
|
||||
// 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 = device.User.Id,
|
||||
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,
|
||||
@@ -521,7 +543,7 @@ public sealed class AdminService : IAdminService
|
||||
IsOwner = true
|
||||
});
|
||||
|
||||
result.AddRange(members.Select(du => new AdminDeviceMemberDto
|
||||
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<IReadOnlyList<AdminUserGrowthRowDto>> 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<AdminUserGrowthRowDto>();
|
||||
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)
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<div className="relative w-52">
|
||||
<SearchInput
|
||||
value={deviceSearch}
|
||||
onValueChange={(v) => { 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}
|
||||
/>
|
||||
|
||||
@@ -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() {
|
||||
<>
|
||||
<SearchInput
|
||||
value={adminSearch}
|
||||
onValueChange={(v) => { setAdminSearch(v); searchAdmins(v) }}
|
||||
onValueChange={(v) => setAdminSearch(v)}
|
||||
onSubmit={(v) => searchAdmins(v ?? adminSearch)}
|
||||
placeholder="فیلتر ادمین..."
|
||||
showSearchButton={false}
|
||||
|
||||
@@ -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 ? (
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400" dir="ltr">{toPersianDigits(owner.mobile)}</span>
|
||||
<button
|
||||
type="button"
|
||||
title="تغییر مالک"
|
||||
className="p-1 rounded text-green-700 hover:bg-green-100"
|
||||
onClick={() => { setOwner(null); setOwnerResults([]); setOwnerSearch('') }}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SearchInput
|
||||
value={ownerSearch}
|
||||
onValueChange={(v) => { setOwnerSearch(v); if (v !== undefined) searchOwners(v) }}
|
||||
onValueChange={(v) => setOwnerSearch(v)}
|
||||
onSubmit={(v) => { const s = v ?? ownerSearch; setOwnerSearch(s); searchOwners(s) }}
|
||||
placeholder="جستجو بر اساس نام یا موبایل..."
|
||||
/>
|
||||
|
||||
@@ -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() {
|
||||
<div className="relative w-52">
|
||||
<SearchInput
|
||||
value={deviceSearch}
|
||||
onValueChange={(v) => { 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() {
|
||||
<div className="relative w-52">
|
||||
<SearchInput
|
||||
value={authorSearch}
|
||||
onValueChange={(v) => { 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"
|
||||
|
||||
@@ -28,7 +28,7 @@ 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 [preset, setPreset] = useState<RangePreset | null>(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<HTMLInputElement>) => {
|
||||
setter(e.target.value)
|
||||
setPreset(0 as unknown as RangePreset)
|
||||
setPreset(null)
|
||||
}
|
||||
|
||||
const downloadCsv = async () => {
|
||||
|
||||
@@ -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<AdminDeviceTestResult>(`${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<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)
|
||||
|
||||
Reference in New Issue
Block a user