211 lines
8 KiB
TypeScript
211 lines
8 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import DataTable from '@/components/admin/DataTable';
|
|
import DetailModal from '@/components/admin/DetailModal';
|
|
import { MessageSquare, ArrowLeft, RefreshCw, Filter, Calendar, User } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
|
|
const NOTE_TYPE: Record<number, string> = {
|
|
1: 'Task Detail', 2: 'Time Entry Note', 3: 'Ticket Detail',
|
|
};
|
|
|
|
const PUBLISH_MAP: Record<number, { label: string; cls: string }> = {
|
|
1: { label: 'All Users', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
|
|
2: { label: 'Internal', cls: 'bg-amber-500/15 text-amber-700 border border-amber-500/30' },
|
|
4: { label: 'Internal Only', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
|
};
|
|
|
|
export default function TicketNotesPage() {
|
|
const [notes, setNotes] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<any | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(100);
|
|
|
|
const [search, setSearch] = useState('');
|
|
const [sortBy, setSortBy] = useState('create_date_time');
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
|
|
const fetchNotes = async (page = 1, overrideSortBy = sortBy, overrideSortOrder = sortOrder) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (search) params.append('search', search);
|
|
params.append('limit', pageSize.toString());
|
|
params.append('offset', ((page - 1) * pageSize).toString());
|
|
params.append('sort_by', overrideSortBy);
|
|
params.append('sort_order', overrideSortOrder);
|
|
|
|
const res = await fetch(`/api/data/ticket-notes?${params}`);
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
const data = await res.json();
|
|
setNotes(data.ticketNotes || []);
|
|
setTotalCount(data.pagination?.total || 0);
|
|
setCurrentPage(page);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unknown error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchNotes(1); }, [pageSize]);
|
|
|
|
const handleSort = (col: string, dir: 'asc' | 'desc') => {
|
|
setSortBy(col);
|
|
setSortOrder(dir);
|
|
fetchNotes(1, col, dir);
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
key: 'ticket_number',
|
|
label: 'Ticket',
|
|
sortable: false,
|
|
render: (v: string) => v
|
|
? <Badge variant="outline" className="font-mono text-xs">{v}</Badge>
|
|
: <span className="text-muted-foreground">—</span>,
|
|
},
|
|
{
|
|
key: 'create_date_time',
|
|
label: 'Date',
|
|
sortable: true,
|
|
render: (v: string) => v
|
|
? <span className="inline-flex items-center gap-1 text-xs"><Calendar className="w-3 h-3" />{new Date(v).toLocaleDateString()}</span>
|
|
: <span className="text-muted-foreground">—</span>,
|
|
},
|
|
{
|
|
key: 'creator_name',
|
|
label: 'Creator',
|
|
sortable: false,
|
|
render: (v: string) => v
|
|
? <span className="inline-flex items-center gap-1 text-xs"><User className="w-3 h-3" />{v}</span>
|
|
: <span className="text-muted-foreground">—</span>,
|
|
},
|
|
{
|
|
key: 'publish',
|
|
label: 'Visibility',
|
|
sortable: true,
|
|
render: (v: number) => {
|
|
const p = PUBLISH_MAP[v];
|
|
return p
|
|
? <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${p.cls}`}>{p.label}</span>
|
|
: <span className="text-muted-foreground text-xs">{v}</span>;
|
|
},
|
|
},
|
|
{
|
|
key: 'note_type',
|
|
label: 'Type',
|
|
sortable: true,
|
|
render: (v: number) => <span className="text-xs text-muted-foreground">{NOTE_TYPE[v] ?? `Type ${v}`}</span>,
|
|
},
|
|
{
|
|
key: 'title',
|
|
label: 'Title',
|
|
sortable: true,
|
|
render: (v: string) => <div className="max-w-xs truncate text-sm" title={v}>{v || <span className="text-muted-foreground italic">No title</span>}</div>,
|
|
},
|
|
{
|
|
key: 'description',
|
|
label: 'Preview',
|
|
sortable: false,
|
|
render: (v: string) => <div className="max-w-sm truncate text-xs text-muted-foreground" title={v}>{v || '—'}</div>,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/admin/data-browser">
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
<span className="hidden sm:inline">Back</span>
|
|
</Button>
|
|
</Link>
|
|
<MessageSquare className="w-8 h-8 text-blue-600" />
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Ticket Notes</h1>
|
|
<p className="text-muted-foreground">Browse notes attached to tickets</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" onClick={() => fetchNotes(currentPage)} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2"><Filter className="w-5 h-5" />Filters</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Search title / description</Label>
|
|
<Input placeholder="Search..." value={search} onChange={e => setSearch(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && fetchNotes(1)} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Page Size</Label>
|
|
<Select value={pageSize.toString()} onValueChange={v => setPageSize(parseInt(v))}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{['50','100','250','500'].map(v => <SelectItem key={v} value={v}>{v}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<Button onClick={() => fetchNotes(1)} disabled={loading}>Apply</Button>
|
|
<Button variant="outline" onClick={() => { setSearch(''); fetchNotes(1); }}>Clear</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Ticket Notes ({totalCount.toLocaleString()} total)</CardTitle>
|
|
<CardDescription>Showing {notes.length} of {totalCount.toLocaleString()} • Click a row to view details</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-md p-4 mb-4">
|
|
<p className="text-red-800 dark:text-red-200">{error}</p>
|
|
</div>
|
|
)}
|
|
<DataTable
|
|
data={notes}
|
|
columns={columns}
|
|
isLoading={loading}
|
|
onRowClick={row => { setSelected(row); setShowModal(true); }}
|
|
totalCount={totalCount}
|
|
page={currentPage}
|
|
pageSize={pageSize}
|
|
onPageChange={p => fetchNotes(p)}
|
|
onSort={handleSort}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<DetailModal
|
|
open={showModal}
|
|
onOpenChange={setShowModal}
|
|
title={selected?.title || `Note #${selected?.id}`}
|
|
data={selected}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|