Добавить auto-capture ошибок и фильтр source у тикетов. Refs EventHub/EventHubBack#35
CI / test (push) Successful in 5m53s
CI / push-image (push) Successful in 10m1s
CI / ci-done (push) Successful in 0s
CI / deploy-ift (push) Successful in 47s
CI / e2e-ift (push) Successful in 2m49s
CI / deploy-stage (push) Successful in 7m58s
CI / e2e-stage (push) Successful in 1m20s
CI / test (push) Successful in 5m53s
CI / push-image (push) Successful in 10m1s
CI / ci-done (push) Successful in 0s
CI / deploy-ift (push) Successful in 47s
CI / e2e-ift (push) Successful in 2m49s
CI / deploy-stage (push) Successful in 7m58s
CI / e2e-stage (push) Successful in 1m20s
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedAuthenticatedSession } from '../helpers/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-capture фронтовых ошибок Admin SPA → POST /v1/tickets (source=frontend).
|
||||||
|
* Ручной «сообщить о проблеме» — клиентский сценарий, не UI Control Center.
|
||||||
|
*/
|
||||||
|
test.describe('frontend error auto-report (mock)', () => {
|
||||||
|
test('reportClientError шлёт POST /v1/tickets с source=frontend', async ({ page }) => {
|
||||||
|
await seedAuthenticatedSession(page);
|
||||||
|
|
||||||
|
let reported: { error_message?: string; source?: string } | null = null;
|
||||||
|
await page.route('**/v1/tickets', async (route) => {
|
||||||
|
if (route.request().method() === 'POST') {
|
||||||
|
reported = route.request().postDataJSON() as typeof reported;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 't-fe-1',
|
||||||
|
source: 'frontend',
|
||||||
|
status: 'open',
|
||||||
|
count: 1,
|
||||||
|
error_hash: 'abc',
|
||||||
|
error_message: reported?.error_message ?? 'err',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await expect(page.getByTestId('layout-shell')).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForFunction(() => typeof window.__ehReportClientError === 'function');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.__ehReportClientError!({
|
||||||
|
message: 'e2e-frontend-auto-report',
|
||||||
|
stack: 'Error: e2e-frontend-auto-report\n at e2e:1:1',
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => reported?.source, { timeout: 10_000 }).toBe('frontend');
|
||||||
|
expect(reported?.error_message).toContain('e2e-frontend-auto-report');
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-1
@@ -1,6 +1,15 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
|
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
|
||||||
|
|
||||||
|
export type TicketSource = 'backend' | 'frontend' | 'manual';
|
||||||
|
|
||||||
|
export interface CreateTicketPayload {
|
||||||
|
error_message: string;
|
||||||
|
stacktrace?: string;
|
||||||
|
context?: Record<string, unknown> | string;
|
||||||
|
source?: TicketSource;
|
||||||
|
}
|
||||||
|
|
||||||
export const ticketsApi = {
|
export const ticketsApi = {
|
||||||
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
|
||||||
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
|
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
|
||||||
@@ -11,6 +20,10 @@ export const ticketsApi = {
|
|||||||
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
|
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
createTicket: async (payload: CreateTicketPayload): Promise<Ticket> => {
|
||||||
|
const { data } = await apiClient.post('/v1/tickets', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
|
||||||
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
|
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
|
||||||
},
|
},
|
||||||
@@ -21,4 +34,4 @@ export const ticketsApi = {
|
|||||||
const { data } = await apiClient.get('/v1/admin/tickets/stats');
|
const { data } = await apiClient.get('/v1/admin/tickets/stats');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
|
import { reportClientError } from '@/lib/errorReporter';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@@ -13,6 +14,15 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
return { error };
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
void reportClientError({
|
||||||
|
message: error.message || 'React render error',
|
||||||
|
stack: error.stack || info.componentStack || undefined,
|
||||||
|
source: 'frontend',
|
||||||
|
context: { component_stack: info.componentStack || undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { ticketsApi, type TicketSource } from '@/api/ticketsApi';
|
||||||
|
import { ACCESS_TOKEN_KEY } from '@/utils/constants';
|
||||||
|
|
||||||
|
const THROTTLE_MS = 60_000;
|
||||||
|
const recentHashes = new Map<string, number>();
|
||||||
|
let reporting = false;
|
||||||
|
|
||||||
|
function simpleHash(input: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return String(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldThrottle(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = recentHashes.get(key) ?? 0;
|
||||||
|
if (now - last < THROTTLE_MS) return true;
|
||||||
|
recentHashes.set(key, now);
|
||||||
|
// prune old entries
|
||||||
|
if (recentHashes.size > 200) {
|
||||||
|
for (const [k, ts] of recentHashes) {
|
||||||
|
if (now - ts > THROTTLE_MS) recentHashes.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContext(extra?: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
route: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||||
|
build: import.meta.env.VITE_APP_VERSION || '0.0',
|
||||||
|
git_sha: import.meta.env.VITE_GIT_SHA || 'dev',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportErrorInput = {
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
source?: TicketSource;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an error to POST /v1/tickets. No-ops without auth token,
|
||||||
|
* while reporting is in flight for this call chain, or when throttled.
|
||||||
|
*/
|
||||||
|
export async function reportClientError(input: ReportErrorInput): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!localStorage.getItem(ACCESS_TOKEN_KEY)) return;
|
||||||
|
if (reporting) return;
|
||||||
|
|
||||||
|
const source = input.source ?? (input.stack ? 'frontend' : 'manual');
|
||||||
|
const message = (input.message || 'Unknown error').slice(0, 2000);
|
||||||
|
const stack = (input.stack || '').slice(0, 8000);
|
||||||
|
const throttleKey = simpleHash(`${source}|${message}|${stack.slice(0, 500)}`);
|
||||||
|
if (shouldThrottle(throttleKey)) return;
|
||||||
|
|
||||||
|
reporting = true;
|
||||||
|
try {
|
||||||
|
await ticketsApi.createTicket({
|
||||||
|
error_message: message,
|
||||||
|
stacktrace: stack || undefined,
|
||||||
|
source,
|
||||||
|
context: buildContext(input.context),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Never rethrow — avoid error loops from the reporter itself.
|
||||||
|
} finally {
|
||||||
|
reporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installGlobalErrorHandlers(): () => void {
|
||||||
|
const onError = (event: ErrorEvent) => {
|
||||||
|
const msg = event.message || 'window.onerror';
|
||||||
|
if (msg.includes('/v1/tickets')) return;
|
||||||
|
void reportClientError({
|
||||||
|
message: msg,
|
||||||
|
stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`,
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
const message =
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: typeof reason === 'string'
|
||||||
|
? reason
|
||||||
|
: 'unhandledrejection';
|
||||||
|
const stack = reason instanceof Error ? reason.stack : undefined;
|
||||||
|
if (message.includes('/v1/tickets')) return;
|
||||||
|
// Skip expected auth failures
|
||||||
|
if (typeof message === 'string' && /\b(401|403)\b/.test(message)) return;
|
||||||
|
void reportClientError({
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
source: 'frontend',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('error', onError);
|
||||||
|
window.addEventListener('unhandledrejection', onRejection);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('error', onError);
|
||||||
|
window.removeEventListener('unhandledrejection', onRejection);
|
||||||
|
};
|
||||||
|
}
|
||||||
+6
-1
@@ -426,7 +426,12 @@
|
|||||||
"detailTitle": "Ticket {{id}}",
|
"detailTitle": "Ticket {{id}}",
|
||||||
"deleteTitle": "Delete ticket?",
|
"deleteTitle": "Delete ticket?",
|
||||||
"notFound": "Ticket not found",
|
"notFound": "Ticket not found",
|
||||||
"statsHint": "Explore · closed {{closed}}, total errors {{errors}}"
|
"statsHint": "Explore · closed {{closed}}, total errors {{errors}}",
|
||||||
|
"source": "Source",
|
||||||
|
"sourceAll": "All sources",
|
||||||
|
"sourceBackend": "Backend",
|
||||||
|
"sourceFrontend": "Frontend",
|
||||||
|
"sourceManual": "Manual"
|
||||||
},
|
},
|
||||||
"subscriptions": {
|
"subscriptions": {
|
||||||
"title": "Subscriptions",
|
"title": "Subscriptions",
|
||||||
|
|||||||
+6
-1
@@ -426,7 +426,12 @@
|
|||||||
"detailTitle": "Тикет {{id}}",
|
"detailTitle": "Тикет {{id}}",
|
||||||
"deleteTitle": "Удалить тикет?",
|
"deleteTitle": "Удалить тикет?",
|
||||||
"notFound": "Тикет не найден",
|
"notFound": "Тикет не найден",
|
||||||
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}"
|
"statsHint": "Explore · закрыто {{closed}}, всего ошибок {{errors}}",
|
||||||
|
"source": "Источник",
|
||||||
|
"sourceAll": "Все источники",
|
||||||
|
"sourceBackend": "Backend",
|
||||||
|
"sourceFrontend": "Frontend",
|
||||||
|
"sourceManual": "Ручной"
|
||||||
},
|
},
|
||||||
"subscriptions": {
|
"subscriptions": {
|
||||||
"title": "Подписки",
|
"title": "Подписки",
|
||||||
|
|||||||
@@ -6,8 +6,20 @@ import './index.css';
|
|||||||
import './i18n';
|
import './i18n';
|
||||||
import LocaleProvider from './components/LocaleProvider';
|
import LocaleProvider from './components/LocaleProvider';
|
||||||
import { initTableDensity } from './lib/density';
|
import { initTableDensity } from './lib/density';
|
||||||
|
import { installGlobalErrorHandlers, reportClientError } from './lib/errorReporter';
|
||||||
|
|
||||||
initTableDensity();
|
initTableDensity();
|
||||||
|
installGlobalErrorHandlers();
|
||||||
|
|
||||||
|
// E2E / debug hook for auto-report smoke (mock suite)
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__ehReportClientError?: typeof reportClientError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__ehReportClientError = reportClientError;
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ const TicketDetailPage: React.FC = () => {
|
|||||||
<dt className="text-muted-foreground">{t('common.sender')}</dt>
|
<dt className="text-muted-foreground">{t('common.sender')}</dt>
|
||||||
<dd>{getUserLink()}</dd>
|
<dd>{getUserLink()}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
|
<dt className="text-muted-foreground">{t('tickets.source')}</dt>
|
||||||
|
<dd>
|
||||||
|
{ticket.source === 'frontend'
|
||||||
|
? t('tickets.sourceFrontend')
|
||||||
|
: ticket.source === 'manual'
|
||||||
|
? t('tickets.sourceManual')
|
||||||
|
: t('tickets.sourceBackend')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||||
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
|
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
|
||||||
<dd>{ticket.error_hash}</dd>
|
<dd>{ticket.error_hash}</dd>
|
||||||
|
|||||||
@@ -23,9 +23,17 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
const TABLE_KEY = 'tickets';
|
const TABLE_KEY = 'tickets';
|
||||||
|
const SOURCE_ALL = '__all__';
|
||||||
|
|
||||||
const isBadValue = (val: unknown) =>
|
const isBadValue = (val: unknown) =>
|
||||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||||
@@ -37,6 +45,12 @@ const ticketStatusVariant = (status: string) => {
|
|||||||
return 'outline' as const;
|
return 'outline' as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function sourceLabel(source: string | undefined, t: TFunction): string {
|
||||||
|
if (source === 'frontend') return t('tickets.sourceFrontend');
|
||||||
|
if (source === 'manual') return t('tickets.sourceManual');
|
||||||
|
return t('tickets.sourceBackend');
|
||||||
|
}
|
||||||
|
|
||||||
function ticketActions(
|
function ticketActions(
|
||||||
handlers: { onOpen: () => void; onDelete: () => void },
|
handlers: { onOpen: () => void; onDelete: () => void },
|
||||||
t: TFunction
|
t: TFunction
|
||||||
@@ -111,6 +125,13 @@ const TicketListPage: React.FC = () => {
|
|||||||
return isBadValue(text) ? '-' : text;
|
return isBadValue(text) ? '-' : text;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'source',
|
||||||
|
header: t('tickets.source'),
|
||||||
|
size: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => sourceLabel(getValue<string | undefined>(), t),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: t('common.status'),
|
header: t('common.status'),
|
||||||
@@ -139,10 +160,13 @@ const TicketListPage: React.FC = () => {
|
|||||||
const record = row.original;
|
const record = row.original;
|
||||||
return (
|
return (
|
||||||
<RowActionsMenu
|
<RowActionsMenu
|
||||||
actions={ticketActions({
|
actions={ticketActions(
|
||||||
onOpen: () => navigate(`/tickets/${record.id}`),
|
{
|
||||||
onDelete: () => handleDelete(record.id),
|
onOpen: () => navigate(`/tickets/${record.id}`),
|
||||||
}, t)}
|
onDelete: () => handleDelete(record.id),
|
||||||
|
},
|
||||||
|
t
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -163,16 +187,22 @@ const TicketListPage: React.FC = () => {
|
|||||||
title,
|
title,
|
||||||
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
|
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
|
||||||
badges: (
|
badges: (
|
||||||
<Badge variant={ticketStatusVariant(record.status)}>
|
<>
|
||||||
{formatStatusLabel(record.status)}
|
<Badge variant="outline">{sourceLabel(record.source, t)}</Badge>
|
||||||
</Badge>
|
<Badge variant={ticketStatusVariant(record.status)}>
|
||||||
|
{formatStatusLabel(record.status)}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
meta: `×${record.count} · ${record.last_seen}`,
|
meta: `×${record.count} · ${record.last_seen}`,
|
||||||
onActivate: () => navigate(`/tickets/${record.id}`),
|
onActivate: () => navigate(`/tickets/${record.id}`),
|
||||||
actions: ticketActions({
|
actions: ticketActions(
|
||||||
onOpen: () => navigate(`/tickets/${record.id}`),
|
{
|
||||||
onDelete: () => handleDelete(record.id),
|
onOpen: () => navigate(`/tickets/${record.id}`),
|
||||||
}, t),
|
onDelete: () => handleDelete(record.id),
|
||||||
|
},
|
||||||
|
t
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [data?.data, navigate, t]);
|
}, [data?.data, navigate, t]);
|
||||||
@@ -189,6 +219,32 @@ const TicketListPage: React.FC = () => {
|
|||||||
to: '/inbox/tickets',
|
to: '/inbox/tickets',
|
||||||
actionLabel: t('common.openInbox'),
|
actionLabel: t('common.openInbox'),
|
||||||
}}
|
}}
|
||||||
|
toolbar={
|
||||||
|
<Select
|
||||||
|
value={params.source ?? SOURCE_ALL}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = { ...prev, offset: 0 };
|
||||||
|
if (value === SOURCE_ALL) {
|
||||||
|
delete next.source;
|
||||||
|
} else {
|
||||||
|
next.source = value as TicketListParams['source'];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]" data-testid="filter-ticket-source">
|
||||||
|
<SelectValue placeholder={t('tickets.source')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={SOURCE_ALL}>{t('tickets.sourceAll')}</SelectItem>
|
||||||
|
<SelectItem value="backend">{t('tickets.sourceBackend')}</SelectItem>
|
||||||
|
<SelectItem value="frontend">{t('tickets.sourceFrontend')}</SelectItem>
|
||||||
|
<SelectItem value="manual">{t('tickets.sourceManual')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ExploreDataView
|
<ExploreDataView
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -203,14 +259,20 @@ const TicketListPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</ExploreListShell>
|
</ExploreListShell>
|
||||||
|
|
||||||
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
|
<Dialog
|
||||||
|
open={deleteConfirm.open}
|
||||||
|
onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
|
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
|
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteConfirm({ open: false, ticketId: null })}
|
||||||
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
|
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
|
||||||
|
|||||||
@@ -239,10 +239,12 @@ export interface Ticket {
|
|||||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
assigned_to: string | null;
|
assigned_to: string | null;
|
||||||
resolution_note: string | null;
|
resolution_note: string | null;
|
||||||
|
source?: 'backend' | 'frontend' | 'manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketListParams {
|
export interface TicketListParams {
|
||||||
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
|
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
source?: 'backend' | 'frontend' | 'manual';
|
||||||
assigned_to?: string;
|
assigned_to?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user