fix(admin): UX Control Center — даты Inbox, bulk, фильтры событий, палитра по ID
Refs EventHub/EventHubFrontAdmin#32 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { NAV_GROUPS, filterNavGroupsByRole } from '@/config/adminAccess';
|
import { NAV_GROUPS, filterNavGroupsByRole, canAccessRoute } from '@/config/adminAccess';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,6 +24,9 @@ interface CommandPaletteProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Looks like EventHub entity ids (base64url-ish). */
|
||||||
|
const ENTITY_ID_RE = /^[A-Za-z0-9_-]{16,40}$/;
|
||||||
|
|
||||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -54,15 +57,38 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|||||||
}, [role, t]);
|
}, [role, t]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim();
|
||||||
if (!q) return items;
|
const qLower = q.toLowerCase();
|
||||||
return items.filter(
|
const navHits = !qLower
|
||||||
(item) =>
|
? items
|
||||||
item.label.toLowerCase().includes(q) ||
|
: items.filter(
|
||||||
item.group.toLowerCase().includes(q) ||
|
(item) =>
|
||||||
item.path.toLowerCase().includes(q)
|
item.label.toLowerCase().includes(qLower) ||
|
||||||
);
|
item.group.toLowerCase().includes(qLower) ||
|
||||||
}, [items, query]);
|
item.path.toLowerCase().includes(qLower)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ENTITY_ID_RE.test(q)) return navHits;
|
||||||
|
|
||||||
|
const jumps: PaletteItem[] = [];
|
||||||
|
const pushIf = (path: string, labelKey: string, id: string) => {
|
||||||
|
if (canAccessRoute(path.split('?')[0], role)) {
|
||||||
|
jumps.push({
|
||||||
|
id,
|
||||||
|
label: `${t(labelKey)} · ${q}`,
|
||||||
|
group: t('layout.paletteJumpGroup'),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pushIf(`/inbox/reports?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenReportInbox', `jump-report-inbox-${q}`);
|
||||||
|
pushIf(`/reports/${encodeURIComponent(q)}`, 'layout.paletteOpenReport', `jump-report-${q}`);
|
||||||
|
pushIf(`/inbox/tickets?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenTicketInbox', `jump-ticket-inbox-${q}`);
|
||||||
|
pushIf(`/tickets/${encodeURIComponent(q)}`, 'layout.paletteOpenTicket', `jump-ticket-${q}`);
|
||||||
|
pushIf(`/events/${encodeURIComponent(q)}`, 'layout.paletteOpenEvent', `jump-event-${q}`);
|
||||||
|
pushIf(`/users/${encodeURIComponent(q)}`, 'layout.paletteOpenUser', `jump-user-${q}`);
|
||||||
|
return [...jumps, ...navHits];
|
||||||
|
}, [items, query, role, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveIndex(0);
|
setActiveIndex(0);
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export function HeaderNodeMetrics({ className }: { className?: string }) {
|
|||||||
const showArrows = canScroll.left || canScroll.right;
|
const showArrows = canScroll.left || canScroll.right;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 max-w-full items-center gap-0.5', className)}>
|
<div className={cn('flex min-w-0 max-w-full items-center gap-1.5', className)}>
|
||||||
|
<span className="hidden shrink-0 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:inline">
|
||||||
|
{t('layout.nodesLabel')}
|
||||||
|
</span>
|
||||||
{showArrows && (
|
{showArrows && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NodeMetric } from '@/store/metricsStore';
|
import { NodeMetric } from '@/store/metricsStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ const MetricIndicator: React.FC<Props> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const cpu = stats.cpu_utilization ?? 0;
|
const cpu = stats.cpu_utilization ?? 0;
|
||||||
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
||||||
const usedMemory = stats.memory_total ?? 0;
|
const usedMemory = stats.memory_total ?? 0;
|
||||||
@@ -104,15 +106,20 @@ const MetricIndicator: React.FC<Props> = ({
|
|||||||
{cpu.toFixed(0)}%
|
{cpu.toFixed(0)}%
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[240px] -translate-x-1/2 rounded-md border bg-popover px-2 py-1.5 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block">
|
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[240px] -translate-x-1/2 rounded-md border bg-popover px-2 py-1.5 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block">
|
||||||
<div className="font-medium">{node}</div>
|
<div className="font-medium">{t('layout.nodeTooltipTitle', { node })}</div>
|
||||||
<div>
|
<div>
|
||||||
CPU: {cpu.toFixed(1)}%{' '}
|
{t('layout.nodeCpu', { value: cpu.toFixed(1) })}
|
||||||
{prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}
|
{prevCpu !== undefined ? ` (${t('layout.nodeWas', { value: prevCpu.toFixed(1) })})` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ (
|
{t('layout.nodeMemory', {
|
||||||
{memoryPercent.toFixed(1)}%)
|
used: (usedMemory / 1048576).toFixed(0),
|
||||||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
|
total: (totalMemory / 1048576).toFixed(0),
|
||||||
|
percent: memoryPercent.toFixed(1),
|
||||||
|
})}
|
||||||
|
{prevMemoryPercent !== null
|
||||||
|
? ` (${t('layout.nodeWas', { value: prevMemoryPercent.toFixed(1) })})`
|
||||||
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -125,7 +132,11 @@ const MetricIndicator: React.FC<Props> = ({
|
|||||||
className={shellClass}
|
className={shellClass}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={`${node}: CPU ${cpu.toFixed(0)}%, память ${memoryPercent.toFixed(0)}%`}
|
aria-label={t('layout.nodeAria', {
|
||||||
|
node,
|
||||||
|
cpu: cpu.toFixed(0),
|
||||||
|
memory: memoryPercent.toFixed(0),
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+37
-11
@@ -19,6 +19,14 @@
|
|||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"noData": "No data",
|
"noData": "No data",
|
||||||
|
"ageJustNow": "just now",
|
||||||
|
"ageMinutes": "{{count}}m",
|
||||||
|
"ageHours": "{{count}}h",
|
||||||
|
"ageYesterday": "yesterday",
|
||||||
|
"ageDays": "{{count}}d",
|
||||||
|
"selectedCount": "Selected: {{count}}",
|
||||||
|
"bulkReviewed": "Reviewed ({{count}})",
|
||||||
|
"bulkDismissed": "Dismissed ({{count}})",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"emDash": "—",
|
"emDash": "—",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
@@ -140,18 +148,18 @@
|
|||||||
"account": "Account"
|
"account": "Account"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"inboxReports": "Inbox · reports",
|
"inboxReports": "Queue · reports",
|
||||||
"inboxTickets": "Inbox · tickets",
|
"inboxTickets": "Queue · tickets",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"calendars": "Calendars",
|
"calendars": "Calendars",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"subscriptions": "Subscriptions",
|
"subscriptions": "Subscriptions",
|
||||||
"reports": "Reports (list)",
|
"reports": "Reports · archive",
|
||||||
"reviews": "Reviews",
|
"reviews": "Reviews",
|
||||||
"bannedWords": "Banned words",
|
"bannedWords": "Banned words",
|
||||||
"tickets": "Tickets (list)",
|
"tickets": "Tickets · archive",
|
||||||
"admins": "Admins",
|
"admins": "Admins",
|
||||||
"audit": "Audit",
|
"audit": "Audit",
|
||||||
"profile": "My profile"
|
"profile": "My profile"
|
||||||
@@ -167,11 +175,24 @@
|
|||||||
"densityToNormal": "Comfortable density",
|
"densityToNormal": "Comfortable density",
|
||||||
"densityToCompact": "Compact density",
|
"densityToCompact": "Compact density",
|
||||||
"paletteTitle": "Command palette",
|
"paletteTitle": "Command palette",
|
||||||
"palettePlaceholder": "Go to section…",
|
"palettePlaceholder": "Go to a section or paste an ID…",
|
||||||
"paletteHints": "↑↓ navigate · Enter open · Esc close",
|
"paletteHints": "↑↓ navigate · Enter open · Esc close",
|
||||||
|
"paletteOpenReportInbox": "Report in inbox",
|
||||||
|
"paletteOpenReport": "Report detail",
|
||||||
|
"paletteOpenTicketInbox": "Ticket in inbox",
|
||||||
|
"paletteOpenTicket": "Ticket detail",
|
||||||
|
"paletteOpenEvent": "Event detail",
|
||||||
|
"paletteOpenUser": "User detail",
|
||||||
|
"paletteJumpGroup": "By ID",
|
||||||
"nodesNoData": "Nodes: no data",
|
"nodesNoData": "Nodes: no data",
|
||||||
|
"nodesLabel": "Nodes · CPU",
|
||||||
"nodesPrev": "Previous nodes",
|
"nodesPrev": "Previous nodes",
|
||||||
"nodesNext": "Next nodes"
|
"nodesNext": "Next nodes",
|
||||||
|
"nodeTooltipTitle": "Node {{node}} · CPU / RAM",
|
||||||
|
"nodeCpu": "CPU: {{value}}%",
|
||||||
|
"nodeMemory": "Memory: {{used}} / {{total}} MB ({{percent}}%)",
|
||||||
|
"nodeWas": "was {{value}}%",
|
||||||
|
"nodeAria": "{{node}}: CPU {{cpu}}%, memory {{memory}}%"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"viewAria": "List view",
|
"viewAria": "List view",
|
||||||
@@ -200,8 +221,8 @@
|
|||||||
"ownerId": "owner {{id}}"
|
"ownerId": "owner {{id}}"
|
||||||
},
|
},
|
||||||
"inbox": {
|
"inbox": {
|
||||||
"reportsTitle": "Inbox · Reports",
|
"reportsTitle": "Queue · Reports",
|
||||||
"ticketsTitle": "Inbox · Tickets",
|
"ticketsTitle": "Queue · Tickets",
|
||||||
"reportsHints": "j/k · Enter open · 1 reviewed · 2 dismissed · link → preview",
|
"reportsHints": "j/k · Enter open · 1 reviewed · 2 dismissed · link → preview",
|
||||||
"ticketsHints": "j/k · Enter open · 1 resolved · 2 in progress",
|
"ticketsHints": "j/k · Enter open · 1 resolved · 2 in progress",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
@@ -213,8 +234,8 @@
|
|||||||
"selectTicket": "Select a ticket from the queue",
|
"selectTicket": "Select a ticket from the queue",
|
||||||
"reportCard": "Report {{id}}",
|
"reportCard": "Report {{id}}",
|
||||||
"fullCard": "Full card",
|
"fullCard": "Full card",
|
||||||
"bannerReports": "Day-to-day report work lives in the inbox",
|
"bannerReports": "Day-to-day work is in Queue · reports. This is archive and filters.",
|
||||||
"bannerTickets": "Day-to-day ticket work lives in the inbox"
|
"bannerTickets": "Day-to-day work is in Queue · tickets. This is the full list and filters."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"controlCenter": "Control Center",
|
"controlCenter": "Control Center",
|
||||||
@@ -354,7 +375,12 @@
|
|||||||
"onlineLink": "Online link",
|
"onlineLink": "Online link",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"recurrenceInterval": "{{freq}} (interval: {{interval}})",
|
"recurrenceInterval": "{{freq}} (interval: {{interval}})",
|
||||||
"ratingWithCount": "{{avg}} ({{count}} ratings)"
|
"ratingWithCount": "{{avg}} ({{count}} ratings)",
|
||||||
|
"periodChip": "start_time range: {{from}} — {{to}}",
|
||||||
|
"emptyFiltered": "No events match the current filters",
|
||||||
|
"emptyInRange": "No events in the selected period",
|
||||||
|
"widenPeriod": "Widen period",
|
||||||
|
"resetFilters": "Reset filters"
|
||||||
},
|
},
|
||||||
"calendars": {
|
"calendars": {
|
||||||
"title": "Calendars",
|
"title": "Calendars",
|
||||||
|
|||||||
+37
-11
@@ -19,6 +19,14 @@
|
|||||||
"yes": "Да",
|
"yes": "Да",
|
||||||
"no": "Нет",
|
"no": "Нет",
|
||||||
"noData": "Нет данных",
|
"noData": "Нет данных",
|
||||||
|
"ageJustNow": "только что",
|
||||||
|
"ageMinutes": "{{count}} мин",
|
||||||
|
"ageHours": "{{count}} ч",
|
||||||
|
"ageYesterday": "вчера",
|
||||||
|
"ageDays": "{{count}} дн",
|
||||||
|
"selectedCount": "Выбрано: {{count}}",
|
||||||
|
"bulkReviewed": "Рассмотрено ({{count}})",
|
||||||
|
"bulkDismissed": "Отклонено ({{count}})",
|
||||||
"loading": "Загрузка…",
|
"loading": "Загрузка…",
|
||||||
"emDash": "—",
|
"emDash": "—",
|
||||||
"all": "Все",
|
"all": "Все",
|
||||||
@@ -140,18 +148,18 @@
|
|||||||
"account": "Аккаунт"
|
"account": "Аккаунт"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"inboxReports": "Входящие · жалобы",
|
"inboxReports": "Очередь · жалобы",
|
||||||
"inboxTickets": "Входящие · тикеты",
|
"inboxTickets": "Очередь · тикеты",
|
||||||
"dashboard": "Дашборд",
|
"dashboard": "Дашборд",
|
||||||
"monitoring": "Мониторинг",
|
"monitoring": "Мониторинг",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"calendars": "Календари",
|
"calendars": "Календари",
|
||||||
"events": "События",
|
"events": "События",
|
||||||
"subscriptions": "Подписки",
|
"subscriptions": "Подписки",
|
||||||
"reports": "Жалобы (список)",
|
"reports": "Жалобы · архив",
|
||||||
"reviews": "Отзывы",
|
"reviews": "Отзывы",
|
||||||
"bannedWords": "Бан-слова",
|
"bannedWords": "Бан-слова",
|
||||||
"tickets": "Тикеты (список)",
|
"tickets": "Тикеты · архив",
|
||||||
"admins": "Администраторы",
|
"admins": "Администраторы",
|
||||||
"audit": "Аудит",
|
"audit": "Аудит",
|
||||||
"profile": "Мой профиль"
|
"profile": "Мой профиль"
|
||||||
@@ -167,11 +175,24 @@
|
|||||||
"densityToNormal": "Обычная плотность",
|
"densityToNormal": "Обычная плотность",
|
||||||
"densityToCompact": "Компактная плотность",
|
"densityToCompact": "Компактная плотность",
|
||||||
"paletteTitle": "Командная палитра",
|
"paletteTitle": "Командная палитра",
|
||||||
"palettePlaceholder": "Перейти к разделу…",
|
"palettePlaceholder": "Перейти к разделу или вставить ID…",
|
||||||
"paletteHints": "↑↓ навигация · Enter открыть · Esc закрыть",
|
"paletteHints": "↑↓ навигация · Enter открыть · Esc закрыть",
|
||||||
|
"paletteOpenReportInbox": "Жалоба во входящих",
|
||||||
|
"paletteOpenReport": "Карточка жалобы",
|
||||||
|
"paletteOpenTicketInbox": "Тикет во входящих",
|
||||||
|
"paletteOpenTicket": "Карточка тикета",
|
||||||
|
"paletteOpenEvent": "Карточка события",
|
||||||
|
"paletteOpenUser": "Карточка пользователя",
|
||||||
|
"paletteJumpGroup": "По ID",
|
||||||
"nodesNoData": "Ноды: нет данных",
|
"nodesNoData": "Ноды: нет данных",
|
||||||
|
"nodesLabel": "Ноды · CPU",
|
||||||
"nodesPrev": "Предыдущие ноды",
|
"nodesPrev": "Предыдущие ноды",
|
||||||
"nodesNext": "Следующие ноды"
|
"nodesNext": "Следующие ноды",
|
||||||
|
"nodeTooltipTitle": "Нода {{node}} · CPU / RAM",
|
||||||
|
"nodeCpu": "CPU: {{value}}%",
|
||||||
|
"nodeMemory": "Память: {{used}} / {{total}} МБ ({{percent}}%)",
|
||||||
|
"nodeWas": "было {{value}}%",
|
||||||
|
"nodeAria": "{{node}}: CPU {{cpu}}%, память {{memory}}%"
|
||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"viewAria": "Вид списка",
|
"viewAria": "Вид списка",
|
||||||
@@ -200,8 +221,8 @@
|
|||||||
"ownerId": "владелец {{id}}"
|
"ownerId": "владелец {{id}}"
|
||||||
},
|
},
|
||||||
"inbox": {
|
"inbox": {
|
||||||
"reportsTitle": "Входящие · Жалобы",
|
"reportsTitle": "Очередь · Жалобы",
|
||||||
"ticketsTitle": "Входящие · Тикеты",
|
"ticketsTitle": "Очередь · Тикеты",
|
||||||
"reportsHints": "j/k · Enter открыть · 1 рассмотрено · 2 отклонено · клик по ссылке → preview",
|
"reportsHints": "j/k · Enter открыть · 1 рассмотрено · 2 отклонено · клик по ссылке → preview",
|
||||||
"ticketsHints": "j/k · Enter открыть · 1 решено · 2 в работу",
|
"ticketsHints": "j/k · Enter открыть · 1 решено · 2 в работу",
|
||||||
"pending": "Ожидают",
|
"pending": "Ожидают",
|
||||||
@@ -213,8 +234,8 @@
|
|||||||
"selectTicket": "Выберите тикет из очереди",
|
"selectTicket": "Выберите тикет из очереди",
|
||||||
"reportCard": "Жалоба {{id}}",
|
"reportCard": "Жалоба {{id}}",
|
||||||
"fullCard": "Полная карточка",
|
"fullCard": "Полная карточка",
|
||||||
"bannerReports": "Оперативная работа с жалобами — во входящих",
|
"bannerReports": "Оперативка — в «Очередь · жалобы». Здесь архив и фильтры.",
|
||||||
"bannerTickets": "Оперативная работа с тикетами — во входящих"
|
"bannerTickets": "Оперативка — в «Очередь · тикеты». Здесь полный список и фильтры."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"controlCenter": "Control Center",
|
"controlCenter": "Control Center",
|
||||||
@@ -354,7 +375,12 @@
|
|||||||
"onlineLink": "Ссылка онлайн",
|
"onlineLink": "Ссылка онлайн",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
"recurrenceInterval": "{{freq}} (интервал: {{interval}})",
|
"recurrenceInterval": "{{freq}} (интервал: {{interval}})",
|
||||||
"ratingWithCount": "{{avg}} ({{count}} оценок)"
|
"ratingWithCount": "{{avg}} ({{count}} оценок)",
|
||||||
|
"periodChip": "Период start_time: {{from}} — {{to}}",
|
||||||
|
"emptyFiltered": "Нет событий по текущим фильтрам",
|
||||||
|
"emptyInRange": "Нет событий в выбранном периоде",
|
||||||
|
"widenPeriod": "Расширить период",
|
||||||
|
"resetFilters": "Сбросить фильтры"
|
||||||
},
|
},
|
||||||
"calendars": {
|
"calendars": {
|
||||||
"title": "Календари",
|
"title": "Календари",
|
||||||
|
|||||||
@@ -237,7 +237,6 @@ const AdminListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('admins.title')}
|
title={t('admins.title')}
|
||||||
description={t('explore.descRowOpen')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -287,7 +287,6 @@ const CalendarListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('calendars.title')}
|
title={t('calendars.title')}
|
||||||
description={t('explore.descPreviewTitle')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
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.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('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.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)} />
|
<KpiCard label={t('dashboard.avgResolutionH')} value={(data.avg_ticket_resolution_h ?? 0).toFixed(1)} />
|
||||||
{data.pending_users_total !== undefined && (
|
{data.pending_users_total !== undefined && (
|
||||||
<KpiCard label={t('common.pendingUsers')} value={data.pending_users_total} icon={<Users className="h-4 w-4" />} />
|
<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 && (
|
{data.subscriptions_total !== undefined && (
|
||||||
<KpiCard label={t('menu.subscriptions')} value={data.subscriptions_total} icon={<CreditCard className="h-4 w-4" />} />
|
<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>[]>(
|
const columns = useMemo<ColumnDef<Event>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -341,17 +377,51 @@ const EventListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('events.title')}
|
title={t('events.title')}
|
||||||
description={t('explore.descPreviewTitle')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
toolbar={
|
toolbar={
|
||||||
<ExploreSearch
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
value={searchDraft}
|
<div className="min-w-0 flex-1">
|
||||||
onChange={setSearchDraft}
|
<ExploreSearch
|
||||||
onSubmit={applySearch}
|
value={searchDraft}
|
||||||
placeholder={t('explore.searchEvents')}
|
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 && (
|
{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
|
<ExploreDataView
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -388,6 +466,7 @@ const EventListPage: React.FC = () => {
|
|||||||
tableKey={TABLE_KEY}
|
tableKey={TABLE_KEY}
|
||||||
params={params}
|
params={params}
|
||||||
onParamsChange={setParams}
|
onParamsChange={setParams}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
/>
|
/>
|
||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useReports, useReport, useUpdateReport } from '@/hooks/useReports';
|
import { useReports, useReport, useUpdateReport } from '@/hooks/useReports';
|
||||||
import { useUser } from '@/hooks/useUsers';
|
import { useUser } from '@/hooks/useUsers';
|
||||||
import { useEvent } from '@/hooks/useEvents';
|
import { useEvent } from '@/hooks/useEvents';
|
||||||
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
|
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
|
||||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||||
|
import { formatDateTime, formatRelativeAge } from '@/utils/datetime';
|
||||||
import { Report } from '@/types/api';
|
import { Report } from '@/types/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -32,6 +32,8 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
const [filter, setFilter] = useState<Filter>('pending');
|
const [filter, setFilter] = useState<Filter>('pending');
|
||||||
const selectedFromUrl = searchParams.get('selected') ?? '';
|
const selectedFromUrl = searchParams.get('selected') ?? '';
|
||||||
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
|
const [preview, setPreview] = useState<ExplorePreviewTarget>(null);
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const [bulkPending, setBulkPending] = useState(false);
|
||||||
|
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -70,6 +72,19 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
[setSearchParams]
|
[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 { data: report, isLoading: detailLoading } = useReport(selectedId);
|
||||||
const updateReport = useUpdateReport();
|
const updateReport = useUpdateReport();
|
||||||
|
|
||||||
@@ -99,8 +114,22 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
[selectedId, updateReport, advanceAfterAction]
|
[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({
|
useInboxHotkeys({
|
||||||
enabled: !preview,
|
enabled: !preview && checkedIds.size === 0,
|
||||||
onNext: () => {
|
onNext: () => {
|
||||||
const next = selectNeighborId(ids, selectedId, 1);
|
const next = selectNeighborId(ids, selectedId, 1);
|
||||||
if (next) selectReport(next);
|
if (next) selectReport(next);
|
||||||
@@ -127,27 +156,74 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('inbox.reportsTitle')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t('inbox.reportsHints')}</p>
|
<p className="text-sm text-muted-foreground">{t('inbox.reportsHints')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
<Button variant={filter === 'pending' ? 'default' : 'outline'} size="sm" onClick={() => setFilter('pending')}>
|
{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')}
|
{t('inbox.pending')}
|
||||||
</Button>
|
</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')}
|
{t('inbox.all')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 p-0">
|
<CardContent className="flex-1 p-0">
|
||||||
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
|
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
|
||||||
{listLoading ? (
|
{listLoading ? (
|
||||||
<div className="space-y-2 p-3">
|
<div className="space-y-1 p-2">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
@@ -155,25 +231,40 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
|
||||||
onClick={() => selectReport(item.id)}
|
|
||||||
className={cn(
|
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'
|
selectedId === item.id && 'bg-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<input
|
||||||
<span className="font-medium text-sm line-clamp-2">{item.reason}</span>
|
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">
|
<Badge variant={reportBadgeVariant(item.status)} className="shrink-0">
|
||||||
{formatStatusLabel(item.status)}
|
{formatStatusLabel(item.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</button>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
</div>
|
||||||
{item.target_type} · {dayjs(item.created_at).format('DD.MM HH:mm')}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -245,7 +336,7 @@ const ReportInboxPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">{t('common.createdAt')}</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { useTickets, useTicket, useUpdateTicket } from '@/hooks/useTickets';
|
import { useTickets, useTicket, useUpdateTicket } from '@/hooks/useTickets';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
|
import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys';
|
||||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||||
|
import { formatRelativeAge } from '@/utils/datetime';
|
||||||
import { Ticket } from '@/types/api';
|
import { Ticket } from '@/types/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -154,9 +154,9 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
<CardContent className="flex-1 p-0">
|
<CardContent className="flex-1 p-0">
|
||||||
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
|
<ScrollArea className="h-full max-h-[calc(100vh-14rem)]">
|
||||||
{listLoading ? (
|
{listLoading ? (
|
||||||
<div className="space-y-2 p-3">
|
<div className="space-y-1 p-2">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-16 w-full" />
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
@@ -169,21 +169,19 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectTicket(item.id)}
|
onClick={() => selectTicket(item.id)}
|
||||||
className={cn(
|
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'
|
selectedId === item.id && 'bg-muted'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
<span className="font-medium text-sm line-clamp-2">
|
{item.error_message || item.id}
|
||||||
{item.error_message || item.id}
|
</span>
|
||||||
</span>
|
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
|
||||||
<Badge variant={ticketBadgeVariant(item.status)} className="shrink-0">
|
×{item.count} · {formatRelativeAge(item.last_seen)}
|
||||||
{formatStatusLabel(item.status)}
|
</span>
|
||||||
</Badge>
|
<Badge variant={ticketBadgeVariant(item.status)} className="shrink-0">
|
||||||
</div>
|
{formatStatusLabel(item.status)}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
</Badge>
|
||||||
×{item.count} · {dayjs(item.last_seen).format('DD.MM HH:mm')}
|
|
||||||
</p>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +232,7 @@ const TicketInboxPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{ticket.stacktrace && (
|
{ticket.stacktrace && (
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ const ReportListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('reports.title')}
|
title={t('reports.title')}
|
||||||
description={t('explore.descActions')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -368,7 +368,6 @@ const ReviewListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('reviews.title')}
|
title={t('reviews.title')}
|
||||||
description={t('explore.descBulk')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -278,7 +278,6 @@ const SubscriptionListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('subscriptions.title')}
|
title={t('subscriptions.title')}
|
||||||
description={t('explore.descActions')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -181,11 +181,6 @@ const TicketListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('tickets.title')}
|
title={t('tickets.title')}
|
||||||
description={
|
|
||||||
stats
|
|
||||||
? t('tickets.statsHint', { closed: stats.closed, errors: stats.total_errors })
|
|
||||||
: t('explore.descActions')
|
|
||||||
}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -330,7 +330,6 @@ const UserListPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<ExploreListShell
|
<ExploreListShell
|
||||||
title={t('users.title')}
|
title={t('users.title')}
|
||||||
description={t('explore.descPreviewName')}
|
|
||||||
stats={exploreStats}
|
stats={exploreStats}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats dates for UI. Tolerates ISO and values already shaped by normalizeData
|
||||||
|
* (`DD.MM.YYYY HH:mm`), so dayjs().format() is not applied twice.
|
||||||
|
*/
|
||||||
|
export function formatDateTime(
|
||||||
|
value: string | null | undefined,
|
||||||
|
pattern: 'DD.MM.YYYY HH:mm' | 'DD.MM HH:mm' = 'DD.MM.YYYY HH:mm'
|
||||||
|
): string {
|
||||||
|
if (!value || value === '-') return '-';
|
||||||
|
|
||||||
|
const localized = value.match(/^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/);
|
||||||
|
if (localized) {
|
||||||
|
const [, dd, mm, yyyy, hh, min] = localized;
|
||||||
|
if (pattern === 'DD.MM HH:mm') {
|
||||||
|
return hh != null ? `${dd}.${mm} ${hh}:${min}` : `${dd}.${mm}`;
|
||||||
|
}
|
||||||
|
return hh != null ? `${dd}.${mm}.${yyyy} ${hh}:${min}` : `${dd}.${mm}.${yyyy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = dayjs(value);
|
||||||
|
if (!d.isValid()) return value;
|
||||||
|
return d.format(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative age for inbox queues (works with ISO or normalizeData-local strings). */
|
||||||
|
export function formatRelativeAge(value: string | null | undefined): string {
|
||||||
|
if (!value || value === '-') return '-';
|
||||||
|
|
||||||
|
let d = dayjs(value);
|
||||||
|
if (!d.isValid()) {
|
||||||
|
const m = value.match(/^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/);
|
||||||
|
if (m) {
|
||||||
|
const [, dd, mm, yyyy, hh = '00', min = '00'] = m;
|
||||||
|
d = dayjs(`${yyyy}-${mm}-${dd}T${hh}:${min}:00`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!d.isValid()) return formatDateTime(value, 'DD.MM HH:mm');
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
const mins = now.diff(d, 'minute');
|
||||||
|
if (mins < 1) return i18n.t('common.ageJustNow');
|
||||||
|
if (mins < 60) return i18n.t('common.ageMinutes', { count: mins });
|
||||||
|
const hours = now.diff(d, 'hour');
|
||||||
|
if (hours < 24) return i18n.t('common.ageHours', { count: hours });
|
||||||
|
const days = now.diff(d, 'day');
|
||||||
|
if (days === 1) return i18n.t('common.ageYesterday');
|
||||||
|
if (days < 7) return i18n.t('common.ageDays', { count: days });
|
||||||
|
return formatDateTime(value, 'DD.MM HH:mm');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user