✨ 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:
2026-09-25 19:04:57 +03:30
parent 69c0381530
commit b557590303
8 changed files with 122 additions and 51 deletions

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text; using System.Text;
using GreenHome.Api.Filters; using GreenHome.Api.Filters;
using GreenHome.Application; using GreenHome.Application;
@@ -261,7 +262,7 @@ public sealed class AdminController : ControllerBase
if (csv) if (csv)
return ToCsvFile("report-users", return ToCsvFile("report-users",
new[] { "تاریخ", "کاربران جدید", "کاربران فعال" }, 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); return Ok(rows);
} }
@@ -274,7 +275,7 @@ public sealed class AdminController : ControllerBase
if (csv) if (csv)
return ToCsvFile("report-devices-uptime", return ToCsvFile("report-devices-uptime",
new[] { "دستگاه", "مالک", "کل روزها", "روزهای آنلاین", "درصد آنلاین" }, 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); return Ok(rows);
} }
@@ -287,13 +288,14 @@ public sealed class AdminController : ControllerBase
if (csv) if (csv)
return ToCsvFile("report-usage-by-user", return ToCsvFile("report-usage-by-user",
new[] { "نام", "نام خانوادگی", "موبایل", "پیامک", "تماس صوتی", "درخواست هوش مصنوعی", "توکن هوش مصنوعی" }, 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); 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) 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; var f = (from ?? t.AddDays(-6)).Date;
return (f, t); return (f, t);
} }

View File

@@ -334,6 +334,9 @@ public sealed class AdminService : IAdminService
if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName, cancellationToken)) if (await _dbContext.Devices.AnyAsync(d => d.DeviceName == deviceName, cancellationToken))
return new AdminDeviceCreateResult { Outcome = AdminDeviceCreateOutcome.ConflictDuplicateName, Message = $"دستگاهی با نام «{deviceName}» از قبل وجود دارد" }; 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 var deviceId = await _deviceService.AddDeviceAsync(new DeviceDto
{ {
DeviceName = deviceName, DeviceName = deviceName,
@@ -504,24 +507,43 @@ public sealed class AdminService : IAdminService
.ThenBy(du => du.UserId) .ThenBy(du => du.UserId)
.ToListAsync(cancellationToken); .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) var result = new List<AdminDeviceMemberDto>(membersWithoutOwner.Count + 1);
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 // 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, UserId = du.UserId,
Name = du.User.Name, 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) public async Task<IReadOnlyList<AdminUserGrowthRowDto>> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
{ {
var fromDate = DateOnly.FromDateTime(from.Date); // ponytail: DateTime.Date is not EF-translatable, so the day filter is a range
var toDate = DateOnly.FromDateTime(to.Date); // 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() var newDates = await _dbContext.Users.AsNoTracking()
.Where(u => DateOnly.FromDateTime(u.CreatedAt) >= fromDate && DateOnly.FromDateTime(u.CreatedAt) <= toDate) .Where(u => u.CreatedAt >= fromDate && u.CreatedAt < toDateExclusive)
.Select(u => new { Date = DateOnly.FromDateTime(u.CreatedAt) }) .Select(u => u.CreatedAt.Date)
.GroupBy(x => x.Date) .ToListAsync(cancellationToken);
.Select(g => new { Date = g.Key, Count = g.Count() }) var newUsers = newDates.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count());
.ToDictionaryAsync(x => x.Date, x => x.Count, cancellationToken);
var activeUsers = await _dbContext.Users.AsNoTracking() var activeDates = await _dbContext.Users.AsNoTracking()
.Where(u => u.LastLoginAt != null .Where(u => u.LastLoginAt != null && u.LastLoginAt >= fromDate && u.LastLoginAt < toDateExclusive)
&& DateOnly.FromDateTime(u.LastLoginAt!.Value) >= fromDate .Select(u => u.LastLoginAt!.Value.Date)
&& DateOnly.FromDateTime(u.LastLoginAt!.Value) <= toDate) .ToListAsync(cancellationToken);
.Select(u => new { Date = DateOnly.FromDateTime(u.LastLoginAt!.Value) }) var activeUsers = activeDates.GroupBy(d => d).ToDictionary(g => g.Key, g => g.Count());
.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>(); 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 rows.Add(new AdminUserGrowthRowDto
{ {
Date = day, Date = key,
NewUsers = newUsers.GetValueOrDefault(day), NewUsers = newUsers.GetValueOrDefault(day),
ActiveUsers = activeUsers.GetValueOrDefault(day) ActiveUsers = activeUsers.GetValueOrDefault(day)
}); });

View File

@@ -10,6 +10,7 @@ import { Badge } from '@/components/common/Badge'
import { Button } from '@/components/common/Button' import { Button } from '@/components/common/Button'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { SearchInput } from '@/components/forms/SearchInput' import { SearchInput } from '@/components/forms/SearchInput'
import { useDebounce } from '@/hooks/useDebounce'
const PAGE_SIZE = 20 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 = () => { const applyLast7Days = () => {
setFrom(daysAgoIso(6)) setFrom(daysAgoIso(6))
setTo(new Date().toISOString().slice(0, 10)) setTo(new Date().toISOString().slice(0, 10))
@@ -134,9 +142,9 @@ export default function AdminAlertsPage() {
<div className="relative w-52"> <div className="relative w-52">
<SearchInput <SearchInput
value={deviceSearch} value={deviceSearch}
onValueChange={(v) => { setDeviceSearch(v); searchDevices(v) }} onValueChange={(v) => setDeviceSearch(v)}
onSubmit={(v) => searchDevices(v ?? deviceSearch)} onSubmit={(v) => searchDevices(v ?? deviceSearch)}
onClear={() => { setDeviceId(''); setDeviceResults([]); setPage(1) }} onClear={() => { setDeviceId(''); setDeviceSearch(''); setDeviceResults([]); setPage(1) }}
placeholder="فیلتر دستگاه..." placeholder="فیلتر دستگاه..."
showSearchButton={false} showSearchButton={false}
/> />

View File

@@ -9,6 +9,7 @@ import { Pagination } from '@/components/navigation/Pagination'
import { Badge } from '@/components/common/Badge' import { Badge } from '@/components/common/Badge'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { SearchInput } from '@/components/forms/SearchInput' import { SearchInput } from '@/components/forms/SearchInput'
import { useDebounce } from '@/hooks/useDebounce'
const PAGE_SIZE = 20 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 = () => { const clearAdminFilter = () => {
setAdminId('') setAdminId('')
setAdminSearch('') setAdminSearch('')
@@ -92,7 +100,7 @@ export default function AdminAuditPage() {
<> <>
<SearchInput <SearchInput
value={adminSearch} value={adminSearch}
onValueChange={(v) => { setAdminSearch(v); searchAdmins(v) }} onValueChange={(v) => setAdminSearch(v)}
onSubmit={(v) => searchAdmins(v ?? adminSearch)} onSubmit={(v) => searchAdmins(v ?? adminSearch)}
placeholder="فیلتر ادمین..." placeholder="فیلتر ادمین..."
showSearchButton={false} showSearchButton={false}

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { FormEvent, useCallback, useEffect, useState } from 'react' 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 { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult, AdminDeviceMember, AdminUserListItem } from '@/lib/api'
import { resolveAssetUrl } from '@/lib/api/assets' import { resolveAssetUrl } from '@/lib/api/assets'
import { toPersianDigits } from '@/lib/format/persian-digits' import { toPersianDigits } from '@/lib/format/persian-digits'
@@ -11,6 +11,7 @@ import { SearchInput } from '@/components/forms/SearchInput'
import { FormInput } from '@/components/forms/FormInput' import { FormInput } from '@/components/forms/FormInput'
import { Select } from '@/components/forms/Select' import { Select } from '@/components/forms/Select'
import { Pagination } from '@/components/navigation/Pagination' import { Pagination } from '@/components/navigation/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
import { Badge } from '@/components/common/Badge' import { Badge } from '@/components/common/Badge'
import { Button } from '@/components/common/Button' import { Button } from '@/components/common/Button'
import { Modal } from '@/components/common/Modal' 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) => { const submit = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
if (!owner) { setMessage({ kind: 'error', text: 'لطفاً مالک دستگاه را انتخاب کنید.' }); return } if (!owner) { setMessage({ kind: 'error', text: 'لطفاً مالک دستگاه را انتخاب کنید.' }); return }
@@ -441,13 +449,23 @@ function CreateDeviceForm({ onCreated }: { onCreated: () => void }) {
{owner ? ( {owner ? (
<div className="flex items-center justify-between bg-green-50 border border-green-200 rounded-lg px-3 py-2"> <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-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 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> </div>
) : ( ) : (
<> <>
<SearchInput <SearchInput
value={ownerSearch} 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) }} onSubmit={(v) => { const s = v ?? ownerSearch; setOwnerSearch(s); searchOwners(s) }}
placeholder="جستجو بر اساس نام یا موبایل..." placeholder="جستجو بر اساس نام یا موبایل..."
/> />

View File

@@ -14,6 +14,7 @@ import { Button } from '@/components/common/Button'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { SegmentTab } from '@/components/common/SegmentTab' import { SegmentTab } from '@/components/common/SegmentTab'
import { Modal } from '@/components/common/Modal' import { Modal } from '@/components/common/Modal'
import { useDebounce } from '@/hooks/useDebounce'
import { useConfirmDialog } from '@/components/utils/ConfirmDialog' import { useConfirmDialog } from '@/components/utils/ConfirmDialog'
import type { AdminDeviceListItem, AdminUserListItem } from '@/lib/api' 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) => { const openDetail = async (post: AdminPostItem) => {
setDetailLoading(true) setDetailLoading(true)
setDetail(null) setDetail(null)
@@ -195,7 +209,7 @@ export default function AdminPostsPage() {
<div className="relative w-52"> <div className="relative w-52">
<SearchInput <SearchInput
value={deviceSearch} 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) }} onSubmit={(v) => { const s = v ?? deviceSearch; setDeviceSearch(s); searchDevices(s) }}
placeholder="فیلتر دستگاه..." placeholder="فیلتر دستگاه..."
className="text-xs" className="text-xs"
@@ -219,7 +233,7 @@ export default function AdminPostsPage() {
<div className="relative w-52"> <div className="relative w-52">
<SearchInput <SearchInput
value={authorSearch} 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) }} onSubmit={(v) => { const s = v ?? authorSearch; setAuthorSearch(s); searchAuthors(s) }}
placeholder="فیلتر نویسنده..." placeholder="فیلتر نویسنده..."
className="text-xs" className="text-xs"

View File

@@ -28,7 +28,7 @@ export default function AdminReportsPage() {
const [tab, setTab] = useState<ReportTab>('users') const [tab, setTab] = useState<ReportTab>('users')
const [from, setFrom] = useState(daysAgo(6)) const [from, setFrom] = useState(daysAgo(6))
const [to, setTo] = useState(new Date().toISOString().slice(0, 10)) 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 [loading, setLoading] = useState(true)
const [csvBusy, setCsvBusy] = useState(false) const [csvBusy, setCsvBusy] = useState(false)
@@ -64,7 +64,7 @@ export default function AdminReportsPage() {
const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => { const onDateChange = (setter: (v: string) => void) => (e: ChangeEvent<HTMLInputElement>) => {
setter(e.target.value) setter(e.target.value)
setPreset(0 as unknown as RangePreset) setPreset(null)
} }
const downloadCsv = async () => { const downloadCsv = async () => {

View File

@@ -259,7 +259,7 @@ export const api = {
adminAlerts: (q: { deviceId?: number; status?: number; from?: string; to?: string; page?: number; pageSize?: number }) => { adminAlerts: (q: { deviceId?: number; status?: number; from?: string; to?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q.deviceId) params.set('deviceId', String(q.deviceId)) 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.from) params.set('from', q.from)
if (q.to) params.set('to', q.to) if (q.to) params.set('to', q.to)
if (q.page) params.set('page', String(q.page)) 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 }) => { adminPosts: (q: { deviceId?: number; authorUserId?: number; postType?: number; from?: string; to?: string; page?: number; pageSize?: number }) => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (q.deviceId) params.set('deviceId', String(q.deviceId)) 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.postType != null) params.set('postType', String(q.postType))
if (q.from) params.set('from', q.from) if (q.from) params.set('from', q.from)
if (q.to) params.set('to', q.to) 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' }), http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-voice`, { method: 'POST' }),
adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => { adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => {
const params = new URLSearchParams() 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.page) params.set('page', String(q.page))
if (q.pageSize) params.set('pageSize', String(q.pageSize)) if (q.pageSize) params.set('pageSize', String(q.pageSize))
return http<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`) return http<PagedResult<AdminAuditLogItem>>(`${API_BASE}/api/admin/audit-logs?${params.toString()}`)