530 lines
17 KiB
TypeScript
530 lines
17 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 {
|
||
|
|
Clock,
|
||
|
|
Users,
|
||
|
|
Ticket,
|
||
|
|
Calendar,
|
||
|
|
ArrowLeft,
|
||
|
|
Home,
|
||
|
|
RefreshCw,
|
||
|
|
Download,
|
||
|
|
Filter,
|
||
|
|
Sparkles,
|
||
|
|
TicketX
|
||
|
|
} from 'lucide-react';
|
||
|
|
import Link from 'next/link';
|
||
|
|
import { TimeEntry } from '@/lib/types/database';
|
||
|
|
|
||
|
|
function cn(...classes: string[]) {
|
||
|
|
return classes.filter(Boolean).join(' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function TimeEntriesPage() {
|
||
|
|
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [selectedEntry, setSelectedEntry] = useState<TimeEntry | null>(null);
|
||
|
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||
|
|
|
||
|
|
// Pagination states
|
||
|
|
const [totalCount, setTotalCount] = useState(0);
|
||
|
|
const [currentPage, setCurrentPage] = useState(1);
|
||
|
|
const [pageSize, setPageSize] = useState(100);
|
||
|
|
|
||
|
|
// Filter states
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [startDate, setStartDate] = useState('');
|
||
|
|
const [endDate, setEndDate] = useState('');
|
||
|
|
const [billable, setBillable] = useState<string>('all');
|
||
|
|
const [approved, setApproved] = useState<string>('all');
|
||
|
|
|
||
|
|
// Sort states
|
||
|
|
const [sortBy, setSortBy] = useState('entry_date');
|
||
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||
|
|
|
||
|
|
// Enrichment and filter states
|
||
|
|
const [enriched, setEnriched] = useState(false);
|
||
|
|
const [hideNonTicket, setHideNonTicket] = useState(true); // Default ON
|
||
|
|
const [enrichedData, setEnrichedData] = useState<Record<string, any>>({});
|
||
|
|
|
||
|
|
const fetchTimeEntries = async (page: number = 1) => {
|
||
|
|
setLoading(true);
|
||
|
|
setError(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const offset = (page - 1) * pageSize;
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
|
||
|
|
if (search) params.append('search', search);
|
||
|
|
if (startDate) params.append('start_date', startDate);
|
||
|
|
if (endDate) params.append('end_date', endDate);
|
||
|
|
if (billable !== 'all') params.append('billable', billable);
|
||
|
|
if (approved !== 'all') params.append('approved', approved);
|
||
|
|
if (hideNonTicket) params.append('has_ticket', 'true');
|
||
|
|
params.append('limit', pageSize.toString());
|
||
|
|
params.append('offset', offset.toString());
|
||
|
|
params.append('sort_by', sortBy);
|
||
|
|
params.append('sort_order', sortOrder);
|
||
|
|
|
||
|
|
const response = await fetch(`/api/data/time-entries?${params}`);
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
setTimeEntries(data.timeEntries || []);
|
||
|
|
setTotalCount(data.pagination?.total || 0);
|
||
|
|
setCurrentPage(page);
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||
|
|
setError(errorMessage);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchTimeEntries(1);
|
||
|
|
}, [pageSize]);
|
||
|
|
|
||
|
|
const handleRefresh = () => {
|
||
|
|
fetchTimeEntries(currentPage);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handlePageChange = (newPage: number) => {
|
||
|
|
fetchTimeEntries(newPage);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSort = async (column: string, direction: 'asc' | 'desc') => {
|
||
|
|
setSortBy(column);
|
||
|
|
setSortOrder(direction);
|
||
|
|
|
||
|
|
// Fetch with new sort parameters
|
||
|
|
setLoading(true);
|
||
|
|
setError(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
|
||
|
|
if (search) params.append('search', search);
|
||
|
|
if (startDate) params.append('start_date', startDate);
|
||
|
|
if (endDate) params.append('end_date', endDate);
|
||
|
|
if (billable !== 'all') params.append('billable', billable);
|
||
|
|
if (approved !== 'all') params.append('approved', approved);
|
||
|
|
if (hideNonTicket) params.append('has_ticket', 'true');
|
||
|
|
params.append('limit', pageSize.toString());
|
||
|
|
params.append('offset', '0'); // Reset to first page
|
||
|
|
params.append('sort_by', column);
|
||
|
|
params.append('sort_order', direction);
|
||
|
|
|
||
|
|
const response = await fetch(`/api/data/time-entries?${params}`);
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to fetch time entries: ${response.statusText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
setTimeEntries(data.timeEntries || []);
|
||
|
|
setTotalCount(data.pagination?.total || 0);
|
||
|
|
setCurrentPage(1); // Reset to page 1
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||
|
|
setError(errorMessage);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleApplyFilters = () => {
|
||
|
|
fetchTimeEntries(1);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleEnrichData = async () => {
|
||
|
|
if (enriched) {
|
||
|
|
// Toggle off - clear enriched data
|
||
|
|
setEnriched(false);
|
||
|
|
setEnrichedData({});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Extract unique resource and ticket IDs
|
||
|
|
const resourceIds = [...new Set(timeEntries.map(e => e.resource_id).filter(Boolean))];
|
||
|
|
const ticketIds = [...new Set(timeEntries.map(e => e.ticket_id).filter(Boolean))];
|
||
|
|
|
||
|
|
// Fetch resource and ticket data
|
||
|
|
const [resourcesRes, ticketsRes] = await Promise.all([
|
||
|
|
fetch(`/api/data/resources?ids=${resourceIds.join(',')}`),
|
||
|
|
fetch(`/api/data/tickets?ids=${ticketIds.join(',')}`)
|
||
|
|
]);
|
||
|
|
|
||
|
|
const resources = await resourcesRes.json();
|
||
|
|
const tickets = await ticketsRes.json();
|
||
|
|
|
||
|
|
// Build lookup maps
|
||
|
|
const enrichmentMap: Record<string, any> = {};
|
||
|
|
|
||
|
|
resources.resources?.forEach((r: any) => {
|
||
|
|
enrichmentMap[`resource_${r.id}`] = `${r.first_name} ${r.last_name}`;
|
||
|
|
});
|
||
|
|
|
||
|
|
tickets.tickets?.forEach((t: any) => {
|
||
|
|
enrichmentMap[`ticket_${t.id}`] = t.ticket_number;
|
||
|
|
});
|
||
|
|
|
||
|
|
setEnrichedData(enrichmentMap);
|
||
|
|
setEnriched(true);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to enrich data:', error);
|
||
|
|
setError('Failed to enrich data');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleHideNonTicket = () => {
|
||
|
|
setHideNonTicket(!hideNonTicket);
|
||
|
|
fetchTimeEntries(1);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleClearFilters = () => {
|
||
|
|
setSearch('');
|
||
|
|
setStartDate('');
|
||
|
|
setEndDate('');
|
||
|
|
setBillable('all');
|
||
|
|
setApproved('all');
|
||
|
|
setPageSize(100);
|
||
|
|
setCurrentPage(1);
|
||
|
|
// Fetch will be triggered by useEffect when pageSize changes
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleExport = async () => {
|
||
|
|
try {
|
||
|
|
const params = new URLSearchParams({ format: 'csv' });
|
||
|
|
|
||
|
|
if (search) params.append('search', search);
|
||
|
|
if (startDate) params.append('start_date', startDate);
|
||
|
|
if (endDate) params.append('end_date', endDate);
|
||
|
|
if (billable !== 'all') params.append('billable', billable);
|
||
|
|
if (approved !== 'all') params.append('approved', approved);
|
||
|
|
|
||
|
|
const response = await fetch(`/api/data/time-entries/export?${params}`);
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to export data: ${response.statusText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Download file
|
||
|
|
const blob = await response.blob();
|
||
|
|
const url = window.URL.createObjectURL(blob);
|
||
|
|
const a = document.createElement('a');
|
||
|
|
a.href = url;
|
||
|
|
a.download = `time-entries-${new Date().toISOString().split('T')[0]}.csv`;
|
||
|
|
document.body.appendChild(a);
|
||
|
|
a.click();
|
||
|
|
window.URL.revokeObjectURL(url);
|
||
|
|
document.body.removeChild(a);
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||
|
|
setError(errorMessage);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleRowClick = (entry: TimeEntry) => {
|
||
|
|
setSelectedEntry(entry);
|
||
|
|
setShowDetailModal(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const columns = [
|
||
|
|
{
|
||
|
|
key: 'id',
|
||
|
|
label: 'ID',
|
||
|
|
sortable: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'resource_id',
|
||
|
|
label: 'Resource',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: number) => {
|
||
|
|
const displayValue = enriched && enrichedData[`resource_${value}`]
|
||
|
|
? enrichedData[`resource_${value}`]
|
||
|
|
: value;
|
||
|
|
return (
|
||
|
|
<Badge variant="outline">
|
||
|
|
<Users className="w-3 h-3 mr-1" />
|
||
|
|
{displayValue}
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'ticket_id',
|
||
|
|
label: 'Ticket',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: number) => {
|
||
|
|
if (!value) return <span className="text-muted-foreground">—</span>;
|
||
|
|
const displayValue = enriched && enrichedData[`ticket_${value}`]
|
||
|
|
? enrichedData[`ticket_${value}`]
|
||
|
|
: value;
|
||
|
|
return (
|
||
|
|
<Badge variant="outline">
|
||
|
|
<Ticket className="w-3 h-3 mr-1" />
|
||
|
|
{displayValue}
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'entry_date',
|
||
|
|
label: 'Date',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: string) => (
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Calendar className="w-3 h-3" />
|
||
|
|
{new Date(value).toLocaleDateString()}
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'hours_worked',
|
||
|
|
label: 'Hours',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: number | string) => {
|
||
|
|
const hours = typeof value === 'string' ? parseFloat(value) : value;
|
||
|
|
return (
|
||
|
|
<Badge variant={hours > 4 ? 'destructive' : 'secondary'}>
|
||
|
|
<Clock className="w-3 h-3 mr-1" />
|
||
|
|
{hours.toFixed(1)}h
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'title',
|
||
|
|
label: 'Title',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: string) => (
|
||
|
|
<div className="max-w-48 truncate" title={value}>
|
||
|
|
{value || 'No title'}
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'billable',
|
||
|
|
label: 'Billable',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: boolean) => (
|
||
|
|
<Badge variant={value ? 'default' : 'secondary'}>
|
||
|
|
{value ? 'Yes' : 'No'}
|
||
|
|
</Badge>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'approved',
|
||
|
|
label: 'Approved',
|
||
|
|
sortable: true,
|
||
|
|
render: (value: boolean) => (
|
||
|
|
<Badge variant={value ? 'default' : 'destructive'}>
|
||
|
|
{value ? 'Yes' : 'No'}
|
||
|
|
</Badge>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="container mx-auto p-6 space-y-6">
|
||
|
|
{/* Header */}
|
||
|
|
<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>
|
||
|
|
<Clock className="w-8 h-8 text-blue-600" />
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold">Time Entries</h1>
|
||
|
|
<p className="text-muted-foreground">Browse and analyze time tracking data</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
variant={hideNonTicket ? "default" : "outline"}
|
||
|
|
onClick={toggleHideNonTicket}
|
||
|
|
className={hideNonTicket ? "bg-blue-600 hover:bg-blue-700" : ""}
|
||
|
|
>
|
||
|
|
<TicketX className="w-4 h-4 mr-2" />
|
||
|
|
{hideNonTicket ? 'Tickets Only' : 'Show All'}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant={enriched ? "default" : "outline"}
|
||
|
|
onClick={handleEnrichData}
|
||
|
|
disabled={loading || timeEntries.length === 0}
|
||
|
|
className={enriched ? "bg-purple-600 hover:bg-purple-700" : ""}
|
||
|
|
>
|
||
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
||
|
|
{enriched ? 'Enriched' : 'Enrich'}
|
||
|
|
</Button>
|
||
|
|
<Link href="/admin/analytics/time-entries">
|
||
|
|
<Button variant="outline">
|
||
|
|
<Filter className="w-4 h-4 mr-2" />
|
||
|
|
Analytics
|
||
|
|
</Button>
|
||
|
|
</Link>
|
||
|
|
<Button variant="outline" onClick={handleExport}>
|
||
|
|
<Download className="w-4 h-4 mr-2" />
|
||
|
|
Export
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" onClick={handleRefresh} disabled={loading}>
|
||
|
|
<RefreshCw className={cn("w-4 h-4 mr-2", loading ? "animate-spin" : "")} />
|
||
|
|
Refresh
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Filters */}
|
||
|
|
<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-2 lg:grid-cols-4 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Search</Label>
|
||
|
|
<Input
|
||
|
|
placeholder="Search notes or title..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Start Date</Label>
|
||
|
|
<Input
|
||
|
|
type="date"
|
||
|
|
value={startDate}
|
||
|
|
onChange={(e) => setStartDate(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>End Date</Label>
|
||
|
|
<Input
|
||
|
|
type="date"
|
||
|
|
value={endDate}
|
||
|
|
onChange={(e) => setEndDate(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Page Size</Label>
|
||
|
|
<Select value={pageSize.toString()} onValueChange={(val) => setPageSize(parseInt(val))}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="50">50</SelectItem>
|
||
|
|
<SelectItem value="100">100</SelectItem>
|
||
|
|
<SelectItem value="250">250</SelectItem>
|
||
|
|
<SelectItem value="500">500</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Billable</Label>
|
||
|
|
<Select value={billable} onValueChange={setBillable}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="all">All</SelectItem>
|
||
|
|
<SelectItem value="true">Billable</SelectItem>
|
||
|
|
<SelectItem value="false">Non-billable</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Approved</Label>
|
||
|
|
<Select value={approved} onValueChange={setApproved}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="all">All</SelectItem>
|
||
|
|
<SelectItem value="true">Approved</SelectItem>
|
||
|
|
<SelectItem value="false">Not Approved</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-end gap-2 col-span-1 md:col-span-2">
|
||
|
|
<Button onClick={handleApplyFilters} disabled={loading}>
|
||
|
|
Apply Filters
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" onClick={handleClearFilters}>
|
||
|
|
Clear
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Data Table */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Time Entries ({totalCount.toLocaleString()} total)</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Showing {timeEntries.length} of {totalCount.toLocaleString()} entries • Click on any 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={timeEntries}
|
||
|
|
columns={columns}
|
||
|
|
isLoading={loading}
|
||
|
|
onRowClick={handleRowClick}
|
||
|
|
totalCount={totalCount}
|
||
|
|
page={currentPage}
|
||
|
|
pageSize={pageSize}
|
||
|
|
onPageChange={handlePageChange}
|
||
|
|
onSort={handleSort}
|
||
|
|
/>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Detail Modal */}
|
||
|
|
<DetailModal
|
||
|
|
open={showDetailModal}
|
||
|
|
onOpenChange={(open) => setShowDetailModal(open)}
|
||
|
|
title={`Time Entry #${selectedEntry?.id}`}
|
||
|
|
data={selectedEntry}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|