Two follow-ons after the ⌘K palette:
StatusIndicator → Popover
- The top-bar status light is no longer a direct link to /status.
Clicking it opens a popover with grouped issues (failing
integrations, expired tokens, expiring tokens) so a quick glance
answers "what's broken" without leaving the current page. A "View
full status" link at the bottom routes to /status when needed.
- The trigger keeps the same color rollup so the visual hint is
visible without opening the popover.
DataTable → CSV export
- Optional `exportable` + `exportFilename` props add an "Export CSV"
button next to the search bar. Default behavior exports the current
page; pass `onExportAll` for server-side full-result downloads.
- Built client-side from column defs (label → header, raw value →
cell). BOM-prefixed UTF-8 so Excel decodes correctly. Quoting +
escape handled.
- Enabled on /admin/data-browser/{companies,tickets} as initial demos.
Other data-browser pages opt in by adding two props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
5.6 KiB
TypeScript
189 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import DataTable from '@/components/admin/DataTable';
|
|
import DetailModal from '@/components/admin/DetailModal';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ArrowLeft, Ticket } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
|
|
export default function TicketsBrowserPage() {
|
|
const router = useRouter();
|
|
const [tickets, setTickets] = useState([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize] = useState(50);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
const fetchTickets = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
|
|
setIsLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: currentPage.toString(),
|
|
limit: pageSize.toString(),
|
|
});
|
|
|
|
if (search) params.append('search', search);
|
|
if (sortBy) params.append('sort', sortBy);
|
|
if (sortOrder) params.append('order', sortOrder);
|
|
|
|
const response = await fetch(`/api/data/tickets?${params}`);
|
|
const result = await response.json();
|
|
|
|
setTickets(result.data || []);
|
|
setTotalCount(result.pagination?.total || 0);
|
|
} catch (error) {
|
|
console.error('Failed to fetch tickets:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchTickets(page);
|
|
}, [page]);
|
|
|
|
const handleRowClick = (ticket: any) => {
|
|
setSelectedTicket(ticket);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
key: 'id',
|
|
label: 'ID',
|
|
sortable: true,
|
|
},
|
|
{
|
|
key: 'ticket_number',
|
|
label: 'Ticket #',
|
|
sortable: true,
|
|
},
|
|
{
|
|
key: 'title',
|
|
label: 'Title',
|
|
sortable: true,
|
|
render: (value: string) => (
|
|
<div className="max-w-md truncate" title={value}>
|
|
{value}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
sortable: true,
|
|
render: (value: number) => (
|
|
<Badge variant="outline">{value}</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'priority',
|
|
label: 'Priority',
|
|
sortable: true,
|
|
render: (value: number) => {
|
|
const variant = value === 1 ? 'destructive' : value === 2 ? 'default' : 'secondary';
|
|
return <Badge variant={variant}>{value}</Badge>;
|
|
},
|
|
},
|
|
{
|
|
key: 'company_id',
|
|
label: 'Company ID',
|
|
sortable: true,
|
|
},
|
|
{
|
|
key: 'create_date',
|
|
label: 'Created',
|
|
sortable: true,
|
|
render: (value: string) => value ? new Date(value).toLocaleDateString() : '-',
|
|
},
|
|
{
|
|
key: 'is_deleted',
|
|
label: 'Deleted',
|
|
render: (value: boolean) => (
|
|
<Badge variant={value ? 'destructive' : 'secondary'}>
|
|
{value ? 'Yes' : 'No'}
|
|
</Badge>
|
|
),
|
|
},
|
|
];
|
|
|
|
const detailFields = [
|
|
{ key: 'id', label: 'ID' },
|
|
{ key: 'ticket_number', label: 'Ticket Number' },
|
|
{ key: 'title', label: 'Title' },
|
|
{ key: 'description', label: 'Description' },
|
|
{ key: 'status', label: 'Status' },
|
|
{ key: 'priority', label: 'Priority' },
|
|
{ key: 'company_id', label: 'Company ID' },
|
|
{ key: 'contact_id', label: 'Contact ID' },
|
|
{ key: 'assigned_resource_id', label: 'Assigned Resource' },
|
|
{ key: 'queue_id', label: 'Queue ID' },
|
|
{ key: 'issue_type', label: 'Issue Type' },
|
|
{ key: 'sub_issue_type', label: 'Sub Issue Type' },
|
|
{ key: 'source', label: 'Source' },
|
|
{ key: 'due_date_time', label: 'Due Date' },
|
|
{ key: 'estimated_hours', label: 'Estimated Hours' },
|
|
{ key: 'completed_date', label: 'Completed Date' },
|
|
{ key: 'create_date', label: 'Created Date' },
|
|
{ key: 'last_activity_date', label: 'Last Activity' },
|
|
{ key: 'synced_at', label: 'Synced At' },
|
|
{ key: 'is_deleted', label: 'Is Deleted' },
|
|
];
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/admin/data-browser">
|
|
<Button variant="ghost" size="sm">
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
</Link>
|
|
<Ticket className="w-6 h-6" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Tickets Browser</h1>
|
|
<p className="text-sm text-muted-foreground">Browse and inspect ticket data</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Tickets</CardTitle>
|
|
<CardDescription>
|
|
{totalCount} total tickets in database
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
columns={columns}
|
|
data={tickets}
|
|
totalCount={totalCount}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
onPageChange={setPage}
|
|
onSort={(column, direction) => fetchTickets(page, undefined, column, direction)}
|
|
onSearch={(query) => fetchTickets(1, query)}
|
|
onRowClick={handleRowClick}
|
|
isLoading={isLoading}
|
|
exportable
|
|
exportFilename="tickets"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<DetailModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
title={`Ticket #${selectedTicket?.ticket_number || selectedTicket?.id}`}
|
|
data={selectedTicket}
|
|
fields={detailFields}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|