fix(admin): UX Control Center — даты Inbox, bulk, фильтры событий, палитра по ID
Refs EventHub/EventHubFrontAdmin#32 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -237,7 +237,6 @@ const AdminListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('admins.title')}
|
||||
description={t('explore.descRowOpen')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
@@ -287,7 +287,6 @@ const CalendarListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('calendars.title')}
|
||||
description={t('explore.descPreviewTitle')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
@@ -216,19 +216,11 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
<KpiCard label={t('nav.reviews')} value={data.reviews_total} icon={<Star className="h-4 w-4" />} />
|
||||
<KpiCard label={t('nav.calendars')} value={data.calendars_total} icon={<Calendar className="h-4 w-4" />} />
|
||||
<KpiCard label={t('menu.reports')} value={data.reports_total} icon={<AlertTriangle className="h-4 w-4" />} />
|
||||
<KpiCard label={t('dashboard.ticketsTotal')} value={data.tickets_total} icon={<Bug className="h-4 w-4" />} />
|
||||
<KpiCard label={t('dashboard.ticketsOpen')} value={data.tickets_open} icon={<Clock className="h-4 w-4" />} />
|
||||
<KpiCard label={t('dashboard.avgResolutionH')} value={(data.avg_ticket_resolution_h ?? 0).toFixed(1)} />
|
||||
{data.pending_users_total !== undefined && (
|
||||
<KpiCard label={t('common.pendingUsers')} value={data.pending_users_total} icon={<Users className="h-4 w-4" />} />
|
||||
)}
|
||||
{data.reports_pending !== undefined && (
|
||||
<KpiCard
|
||||
label={`${t('menu.reports')}: ${t('status.pending')}`}
|
||||
value={data.reports_pending}
|
||||
/>
|
||||
)}
|
||||
{data.subscriptions_total !== undefined && (
|
||||
<KpiCard label={t('menu.subscriptions')} value={data.subscriptions_total} icon={<CreditCard className="h-4 w-4" />} />
|
||||
)}
|
||||
|
||||
@@ -244,6 +244,42 @@ const EventListPage: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const widenPeriod = () => {
|
||||
const now = Date.now();
|
||||
const yearMs = 365 * 24 * 60 * 60 * 1000;
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
from: new Date(now - 15 * yearMs).toISOString(),
|
||||
to: new Date(now + 5 * yearMs).toISOString(),
|
||||
offset: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
const now = Date.now();
|
||||
const yearMs = 365 * 24 * 60 * 60 * 1000;
|
||||
setSearchDraft('');
|
||||
setParams({
|
||||
limit: getSavedPageSize(TABLE_KEY),
|
||||
offset: 0,
|
||||
sort: 'created_at',
|
||||
order: 'desc',
|
||||
from: new Date(now - 5 * yearMs).toISOString(),
|
||||
to: new Date(now + 2 * yearMs).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const periodLabel = useMemo(() => {
|
||||
if (!params.from || !params.to) return null;
|
||||
return t('events.periodChip', {
|
||||
from: formatDateTime(params.from).slice(0, 10),
|
||||
to: formatDateTime(params.to).slice(0, 10),
|
||||
});
|
||||
}, [params.from, params.to, t]);
|
||||
|
||||
const emptyMessage =
|
||||
params.q || params.status ? t('events.emptyFiltered') : t('events.emptyInRange');
|
||||
|
||||
const columns = useMemo<ColumnDef<Event>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -341,17 +377,51 @@ const EventListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('events.title')}
|
||||
description={t('explore.descPreviewTitle')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
toolbar={
|
||||
<ExploreSearch
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
onSubmit={applySearch}
|
||||
placeholder={t('explore.searchEvents')}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ExploreSearch
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
onSubmit={applySearch}
|
||||
placeholder={t('explore.searchEvents')}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={params.status ?? 'all'}
|
||||
onValueChange={(value) =>
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
status:
|
||||
value === 'all'
|
||||
? undefined
|
||||
: (value as EventListParams['status']),
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-[180px]">
|
||||
<SelectValue placeholder={t('common.allStatuses')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.allStatuses')}</SelectItem>
|
||||
<SelectItem value="active">{formatStatusLabel('active')}</SelectItem>
|
||||
<SelectItem value="cancelled">{formatStatusLabel('cancelled')}</SelectItem>
|
||||
<SelectItem value="completed">{formatStatusLabel('completed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(params.q || params.status) && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||
{t('events.resetFilters')}
|
||||
</Button>
|
||||
)}
|
||||
{periodLabel && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{periodLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{stats?.top_events_by_rating && stats.top_events_by_rating.length > 0 && (
|
||||
@@ -378,6 +448,14 @@ const EventListPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && (data?.data?.length ?? 0) === 0 && !params.q && !params.status && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={widenPeriod}>
|
||||
{t('events.widenPeriod')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExploreDataView
|
||||
viewMode={viewMode}
|
||||
columns={columns}
|
||||
@@ -388,6 +466,7 @@ const EventListPage: React.FC = () => {
|
||||
tableKey={TABLE_KEY}
|
||||
params={params}
|
||||
onParamsChange={setParams}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</ExploreListShell>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { useReports, useReport, useUpdateReport } from '@/hooks/useReports';
|
||||
import { useUser } from '@/hooks/useUsers';
|
||||
import { useEvent } from '@/hooks/useEvents';
|
||||
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { formatDateTime, formatRelativeAge } from '@/utils/datetime';
|
||||
import { Report } from '@/types/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -32,6 +32,8 @@ const ReportInboxPage: React.FC = () => {
|
||||
const [filter, setFilter] = useState<Filter>('pending');
|
||||
const selectedFromUrl = searchParams.get('selected') ?? '';
|
||||
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(() => new Set());
|
||||
const [bulkPending, setBulkPending] = useState(false);
|
||||
|
||||
const listParams = useMemo(
|
||||
() => ({
|
||||
@@ -70,6 +72,19 @@ const ReportInboxPage: React.FC = () => {
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
const toggleChecked = (id: string, checked: boolean) => {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAllVisible = (checked: boolean) => {
|
||||
setCheckedIds(checked ? new Set(ids) : new Set());
|
||||
};
|
||||
|
||||
const { data: report, isLoading: detailLoading } = useReport(selectedId);
|
||||
const updateReport = useUpdateReport();
|
||||
|
||||
@@ -99,8 +114,22 @@ const ReportInboxPage: React.FC = () => {
|
||||
[selectedId, updateReport, advanceAfterAction]
|
||||
);
|
||||
|
||||
const handleBulk = async (status: 'reviewed' | 'dismissed') => {
|
||||
const targets = [...checkedIds];
|
||||
if (targets.length === 0) return;
|
||||
setBulkPending(true);
|
||||
try {
|
||||
await Promise.all(targets.map((id) => updateReport.mutateAsync({ id, data: { status } })));
|
||||
setCheckedIds(new Set());
|
||||
const remaining = ids.filter((id) => !targets.includes(id));
|
||||
if (remaining[0]) selectReport(remaining[0]);
|
||||
} finally {
|
||||
setBulkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
useInboxHotkeys({
|
||||
enabled: !preview,
|
||||
enabled: !preview && checkedIds.size === 0,
|
||||
onNext: () => {
|
||||
const next = selectNeighborId(ids, selectedId, 1);
|
||||
if (next) selectReport(next);
|
||||
@@ -127,27 +156,74 @@ const ReportInboxPage: React.FC = () => {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('inbox.reportsHints')}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant={filter === 'pending' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('pending')}>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
{checkedIds.size > 0 && (
|
||||
<>
|
||||
<span className="self-center text-xs text-muted-foreground">
|
||||
{t('common.selectedCount', { count: checkedIds.size })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={bulkPending}
|
||||
onClick={() => void handleBulk('reviewed')}
|
||||
>
|
||||
{t('common.bulkReviewed', { count: checkedIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={bulkPending}
|
||||
onClick={() => void handleBulk('dismissed')}
|
||||
>
|
||||
{t('common.bulkDismissed', { count: checkedIds.size })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={filter === 'pending' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFilter('pending');
|
||||
setCheckedIds(new Set());
|
||||
}}
|
||||
>
|
||||
{t('inbox.pending')}
|
||||
</Button>
|
||||
<Button variant={filter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('all')}>
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFilter('all');
|
||||
setCheckedIds(new Set());
|
||||
}}
|
||||
>
|
||||
{t('inbox.all')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(260px,360px)_1fr]">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-[minmax(280px,380px)_1fr]">
|
||||
<Card className="flex min-h-0 flex-col overflow-hidden">
|
||||
<CardHeader className="py-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3">
|
||||
<CardTitle className="text-base">{t('inbox.queue', { count: items.length })}</CardTitle>
|
||||
{items.length > 0 && (
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5 accent-primary"
|
||||
checked={checkedIds.size > 0 && checkedIds.size === ids.length}
|
||||
onChange={(e) => toggleAllVisible(e.target.checked)}
|
||||
/>
|
||||
{t('common.all')}
|
||||
</label>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0">
|
||||
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
|
||||
{listLoading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
@@ -155,25 +231,40 @@ const ReportInboxPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
<div
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => selectReport(item.id)}
|
||||
className={cn(
|
||||
'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60',
|
||||
'flex w-full items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/60',
|
||||
selectedId === item.id && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-medium text-sm line-clamp-2">{item.reason}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5 shrink-0 accent-primary"
|
||||
checked={checkedIds.has(item.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleChecked(item.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={item.reason}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectReport(item.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{item.reason}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||
{item.target_type} · {formatRelativeAge(item.created_at)}
|
||||
</span>
|
||||
<Badge variant={reportBadgeVariant(item.status)} className="shrink-0">
|
||||
{formatStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{item.target_type} · {dayjs(item.created_at).format('DD.MM HH:mm')}
|
||||
</p>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -245,7 +336,7 @@ const ReportInboxPage: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
|
||||
<dd>{dayjs(report.created_at).format('DD.MM.YYYY HH:mm')}</dd>
|
||||
<dd>{formatDateTime(report.created_at)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Separator />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTickets, useTicket, useUpdateTicket } from '@/hooks/useTickets';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { formatRelativeAge } from '@/utils/datetime';
|
||||
import { Ticket } from '@/types/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -154,9 +154,9 @@ const TicketInboxPage: React.FC = () => {
|
||||
<CardContent className="flex-1 p-0">
|
||||
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
|
||||
{listLoading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
@@ -169,21 +169,19 @@ const TicketInboxPage: React.FC = () => {
|
||||
type="button"
|
||||
onClick={() => selectTicket(item.id)}
|
||||
className={cn(
|
||||
'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60',
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/60',
|
||||
selectedId === item.id && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-medium text-sm line-clamp-2">
|
||||
{item.error_message || item.id}
|
||||
</span>
|
||||
<Badge variant={ticketBadgeVariant(item.status)} className="shrink-0">
|
||||
{formatStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
×{item.count} · {dayjs(item.last_seen).format('DD.MM HH:mm')}
|
||||
</p>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{item.error_message || item.id}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||
×{item.count} · {formatRelativeAge(item.last_seen)}
|
||||
</span>
|
||||
<Badge variant={ticketBadgeVariant(item.status)} className="shrink-0">
|
||||
{formatStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -234,7 +232,7 @@ const TicketInboxPage: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
|
||||
<dd>{dayjs(ticket.last_seen).format('DD.MM.YYYY HH:mm')}</dd>
|
||||
<dd>{formatRelativeAge(ticket.last_seen)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{ticket.stacktrace && (
|
||||
|
||||
@@ -251,7 +251,6 @@ const ReportListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('reports.title')}
|
||||
description={t('explore.descActions')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
@@ -368,7 +368,6 @@ const ReviewListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('reviews.title')}
|
||||
description={t('explore.descBulk')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
@@ -278,7 +278,6 @@ const SubscriptionListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('subscriptions.title')}
|
||||
description={t('explore.descActions')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
@@ -181,11 +181,6 @@ const TicketListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('tickets.title')}
|
||||
description={
|
||||
stats
|
||||
? t('tickets.statsHint', { closed: stats.closed, errors: stats.total_errors })
|
||||
: t('explore.descActions')
|
||||
}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
@@ -330,7 +330,6 @@ const UserListPage: React.FC = () => {
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('users.title')}
|
||||
description={t('explore.descPreviewName')}
|
||||
stats={exploreStats}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
|
||||
Reference in New Issue
Block a user