- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { TimeEntry } from '@/lib/types/database';
|
|
import {
|
|
TimelineEvent,
|
|
AnalyticsInsight,
|
|
LLMAnalysisResponse,
|
|
AggregateAnalysis
|
|
} from '@/lib/types/analytics';
|
|
import {
|
|
analyticsIntegration,
|
|
EnrichedTimeEntry,
|
|
EnrichmentOptions
|
|
} from '@/lib/services/analytics-integration';
|
|
import { analyticsEngine } from '@/lib/services/analytics-engine';
|
|
import { llmAnalyzer } from '@/lib/services/llm-analyzer';
|
|
|
|
export interface TimeEntriesFilter {
|
|
startDate?: string;
|
|
endDate?: string;
|
|
resourceIds?: number[];
|
|
ticketIds?: number[];
|
|
taskIds?: number[];
|
|
projectIds?: number[];
|
|
companyIds?: number[];
|
|
minHours?: number;
|
|
maxHours?: number;
|
|
billable?: boolean;
|
|
approved?: boolean;
|
|
search?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
sortBy?: string;
|
|
sortOrder?: 'asc' | 'desc';
|
|
}
|
|
|
|
export interface UseTimeEntriesOptions {
|
|
autoFetch?: boolean;
|
|
enableAnalysis?: boolean;
|
|
enableEnrichment?: boolean;
|
|
enrichmentOptions?: EnrichmentOptions;
|
|
cacheKey?: string;
|
|
cacheTimeout?: number; // milliseconds
|
|
}
|
|
|
|
export interface TimeEntriesState {
|
|
timeEntries: TimeEntry[];
|
|
enrichedTimeEntries: EnrichedTimeEntry[];
|
|
timelineEvents: TimelineEvent[];
|
|
analysis: AggregateAnalysis | null;
|
|
insights: AnalyticsInsight[];
|
|
llmAnalysis: LLMAnalysisResponse | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
pagination: {
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
hasMore: boolean;
|
|
};
|
|
}
|
|
|
|
export function useTimeEntries(
|
|
initialFilter: TimeEntriesFilter = {},
|
|
options: UseTimeEntriesOptions = {}
|
|
) {
|
|
const {
|
|
autoFetch = true,
|
|
enableAnalysis = true,
|
|
enableEnrichment = true,
|
|
enrichmentOptions = {
|
|
includeResourceInfo: true,
|
|
includeTicketInfo: true,
|
|
includeProjectInfo: true,
|
|
includeCompanyInfo: true,
|
|
includeAnalysis: true,
|
|
},
|
|
cacheKey = 'time-entries',
|
|
cacheTimeout = 5 * 60 * 1000, // 5 minutes
|
|
} = options;
|
|
|
|
const [filter, setFilter] = useState<TimeEntriesFilter>(initialFilter);
|
|
const [state, setState] = useState<TimeEntriesState>({
|
|
timeEntries: [],
|
|
enrichedTimeEntries: [],
|
|
timelineEvents: [],
|
|
analysis: null,
|
|
insights: [],
|
|
llmAnalysis: null,
|
|
loading: false,
|
|
error: null,
|
|
pagination: {
|
|
total: 0,
|
|
limit: 100,
|
|
offset: 0,
|
|
hasMore: false,
|
|
},
|
|
});
|
|
|
|
// Simple cache implementation
|
|
const cache = useMemo(() => new Map<string, { data: any; timestamp: number }>(), []);
|
|
|
|
const getCacheKey = useCallback((filter: TimeEntriesFilter) => {
|
|
return `${cacheKey}-${JSON.stringify(filter)}`;
|
|
}, [cacheKey]);
|
|
|
|
const getCachedData = useCallback((key: string) => {
|
|
const cached = cache.get(key);
|
|
if (cached && Date.now() - cached.timestamp < cacheTimeout) {
|
|
return cached.data;
|
|
}
|
|
cache.delete(key);
|
|
return null;
|
|
}, [cache, cacheTimeout]);
|
|
|
|
const setCachedData = useCallback((key: string, data: any) => {
|
|
cache.set(key, { data, timestamp: Date.now() });
|
|
}, [cache]);
|
|
|
|
// Fetch time entries from API
|
|
const fetchTimeEntries = useCallback(async (filterOverrides: Partial<TimeEntriesFilter> = {}) => {
|
|
const currentFilter = { ...filter, ...filterOverrides };
|
|
const cacheKey = getCacheKey(currentFilter);
|
|
|
|
// Check cache first
|
|
const cached = getCachedData(cacheKey);
|
|
if (cached) {
|
|
setState(cached);
|
|
return cached;
|
|
}
|
|
|
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
|
|
|
try {
|
|
// Build query string
|
|
const params = new URLSearchParams();
|
|
|
|
if (currentFilter.startDate) params.append('start_date', currentFilter.startDate);
|
|
if (currentFilter.endDate) params.append('end_date', currentFilter.endDate);
|
|
if (currentFilter.minHours) params.append('min_hours', currentFilter.minHours.toString());
|
|
if (currentFilter.maxHours) params.append('max_hours', currentFilter.maxHours.toString());
|
|
if (currentFilter.billable !== undefined) params.append('billable', currentFilter.billable.toString());
|
|
if (currentFilter.approved !== undefined) params.append('approved', currentFilter.approved.toString());
|
|
if (currentFilter.search) params.append('search', currentFilter.search);
|
|
if (currentFilter.limit) params.append('limit', currentFilter.limit.toString());
|
|
if (currentFilter.offset) params.append('offset', currentFilter.offset.toString());
|
|
if (currentFilter.sortBy) params.append('sort_by', currentFilter.sortBy);
|
|
if (currentFilter.sortOrder) params.append('sort_order', currentFilter.sortOrder);
|
|
|
|
if (currentFilter.resourceIds?.length) {
|
|
params.append('resource_ids', currentFilter.resourceIds.join(','));
|
|
}
|
|
if (currentFilter.ticketIds?.length) {
|
|
params.append('ticket_ids', currentFilter.ticketIds.join(','));
|
|
}
|
|
if (currentFilter.taskIds?.length) {
|
|
params.append('task_ids', currentFilter.taskIds.join(','));
|
|
}
|
|
if (currentFilter.projectIds?.length) {
|
|
params.append('project_ids', currentFilter.projectIds.join(','));
|
|
}
|
|
if (currentFilter.companyIds?.length) {
|
|
params.append('company_ids', currentFilter.companyIds.join(','));
|
|
}
|
|
|
|
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();
|
|
const timeEntries: TimeEntry[] = data.timeEntries || [];
|
|
|
|
let enrichedTimeEntries: EnrichedTimeEntry[] = [];
|
|
let timelineEvents: TimelineEvent[] = [];
|
|
let analysis: AggregateAnalysis | null = null;
|
|
let insights: AnalyticsInsight[] = [];
|
|
|
|
// Enrich data if enabled
|
|
if (enableEnrichment && timeEntries.length > 0) {
|
|
enrichedTimeEntries = await analyticsIntegration.enrichTimeEntries(
|
|
timeEntries,
|
|
enrichmentOptions
|
|
);
|
|
|
|
timelineEvents = await analyticsIntegration.generateTimelineEvents(
|
|
timeEntries,
|
|
enrichmentOptions
|
|
);
|
|
}
|
|
|
|
// Generate analysis if enabled
|
|
if (enableAnalysis && timeEntries.length > 0) {
|
|
const analysisResult = await analyticsIntegration.generateComprehensiveAnalysis(
|
|
timeEntries,
|
|
enrichmentOptions
|
|
);
|
|
|
|
analysis = analysisResult.analysis;
|
|
insights = analysisResult.entityInsights;
|
|
}
|
|
|
|
const newState: TimeEntriesState = {
|
|
timeEntries,
|
|
enrichedTimeEntries,
|
|
timelineEvents,
|
|
analysis,
|
|
insights,
|
|
llmAnalysis: null,
|
|
loading: false,
|
|
error: null,
|
|
pagination: data.pagination || {
|
|
total: timeEntries.length,
|
|
limit: currentFilter.limit || 100,
|
|
offset: currentFilter.offset || 0,
|
|
hasMore: false,
|
|
},
|
|
};
|
|
|
|
setState(newState);
|
|
setCachedData(cacheKey, newState);
|
|
|
|
return newState;
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
setState(prev => ({
|
|
...prev,
|
|
loading: false,
|
|
error: errorMessage,
|
|
}));
|
|
throw error;
|
|
}
|
|
}, [filter, enableEnrichment, enableAnalysis, enrichmentOptions, getCacheKey, getCachedData, setCachedData]);
|
|
|
|
// Generate LLM analysis
|
|
const generateLLMAnalysis = useCallback(async (
|
|
analysisType: 'productivity' | 'quality' | 'patterns' | 'anomalies' | 'comprehensive' = 'comprehensive'
|
|
) => {
|
|
if (state.timeEntries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
|
|
|
try {
|
|
const llmAnalysis = await analyticsIntegration.generateLLMAnalysis(
|
|
state.timeEntries,
|
|
analysisType,
|
|
enrichmentOptions
|
|
);
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
llmAnalysis,
|
|
loading: false,
|
|
}));
|
|
|
|
return llmAnalysis;
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
setState(prev => ({
|
|
...prev,
|
|
loading: false,
|
|
error: errorMessage,
|
|
}));
|
|
throw error;
|
|
}
|
|
}, [state.timeEntries, enrichmentOptions]);
|
|
|
|
// Refresh data
|
|
const refresh = useCallback(() => {
|
|
return fetchTimeEntries();
|
|
}, [fetchTimeEntries]);
|
|
|
|
// Update filter
|
|
const updateFilter = useCallback((newFilter: Partial<TimeEntriesFilter>) => {
|
|
setFilter(prev => ({ ...prev, ...newFilter }));
|
|
}, []);
|
|
|
|
// Reset filter
|
|
const resetFilter = useCallback(() => {
|
|
setFilter(initialFilter);
|
|
}, [initialFilter]);
|
|
|
|
// Load more data (pagination)
|
|
const loadMore = useCallback(() => {
|
|
if (!state.pagination.hasMore) return;
|
|
|
|
return fetchTimeEntries({
|
|
offset: state.pagination.offset + state.pagination.limit,
|
|
});
|
|
}, [fetchTimeEntries, state.pagination]);
|
|
|
|
// Export data
|
|
const exportData = useCallback(async (format: 'csv' | 'excel' | 'pdf' | 'json' = 'csv') => {
|
|
try {
|
|
const params = new URLSearchParams({ format });
|
|
|
|
// Apply current filter to export
|
|
Object.entries(filter).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
if (Array.isArray(value)) {
|
|
params.append(key, value.join(','));
|
|
} else {
|
|
params.append(key, String(value));
|
|
}
|
|
}
|
|
});
|
|
|
|
const response = await fetch(`/api/data/time-entries/export?${params}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to export data: ${response.statusText}`);
|
|
}
|
|
|
|
// Get filename from response headers or create default
|
|
const contentDisposition = response.headers.get('content-disposition');
|
|
const filename = contentDisposition
|
|
? contentDisposition.split('filename=')[1]?.replace(/"/g, '')
|
|
: `time-entries-${new Date().toISOString().split('T')[0]}.${format}`;
|
|
|
|
// Download file
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
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';
|
|
setState(prev => ({ ...prev, error: errorMessage }));
|
|
throw error;
|
|
}
|
|
}, [filter]);
|
|
|
|
// Auto-fetch on filter change
|
|
useEffect(() => {
|
|
if (autoFetch) {
|
|
fetchTimeEntries();
|
|
}
|
|
}, [filter, autoFetch, fetchTimeEntries]);
|
|
|
|
// Memoized computed values
|
|
const computed = useMemo(() => ({
|
|
totalEntries: state.timeEntries.length,
|
|
totalHours: state.timeEntries.reduce((sum, entry) => sum + entry.hours_worked, 0),
|
|
averageHoursPerEntry: state.timeEntries.length > 0
|
|
? state.timeEntries.reduce((sum, entry) => sum + entry.hours_worked, 0) / state.timeEntries.length
|
|
: 0,
|
|
billableEntries: state.timeEntries.filter(entry => entry.billable).length,
|
|
approvedEntries: state.timeEntries.filter(entry => entry.approved).length,
|
|
averageScore: state.analysis?.scores.overall || 0,
|
|
}), [state.timeEntries, state.analysis]);
|
|
|
|
return {
|
|
// State
|
|
...state,
|
|
|
|
// Computed values
|
|
computed,
|
|
|
|
// Actions
|
|
fetchTimeEntries,
|
|
generateLLMAnalysis,
|
|
refresh,
|
|
updateFilter,
|
|
resetFilter,
|
|
loadMore,
|
|
exportData,
|
|
|
|
// Filter
|
|
filter,
|
|
setFilter,
|
|
};
|
|
}
|
|
|
|
// Hook for individual time entry analysis
|
|
export function useTimeEntryAnalysis(timeEntryId: number) {
|
|
const [timeEntry, setTimeEntry] = useState<TimeEntry | null>(null);
|
|
const [analysis, setAnalysis] = useState<any>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchTimeEntry = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/data/time-entries/${timeEntryId}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch time entry: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setTimeEntry(data.timeEntry);
|
|
|
|
// Generate analysis
|
|
if (data.timeEntry) {
|
|
const entryAnalysis = analyticsEngine.analyzeTimeEntry(data.timeEntry);
|
|
setAnalysis(entryAnalysis);
|
|
}
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [timeEntryId]);
|
|
|
|
useEffect(() => {
|
|
if (timeEntryId) {
|
|
fetchTimeEntry();
|
|
}
|
|
}, [timeEntryId, fetchTimeEntry]);
|
|
|
|
return {
|
|
timeEntry,
|
|
analysis,
|
|
loading,
|
|
error,
|
|
refresh: fetchTimeEntry,
|
|
};
|
|
}
|
|
|
|
// Hook for real-time analytics updates
|
|
export function useRealTimeAnalytics(refreshInterval: number = 60000) { // 1 minute default
|
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
|
|
const timeEntriesHook = useTimeEntries();
|
|
|
|
// Set up real-time updates
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
timeEntriesHook.refresh();
|
|
setLastUpdate(new Date());
|
|
}, refreshInterval);
|
|
|
|
setIsConnected(true);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
setIsConnected(false);
|
|
};
|
|
}, [refreshInterval, timeEntriesHook]);
|
|
|
|
return {
|
|
...timeEntriesHook,
|
|
lastUpdate,
|
|
isConnected,
|
|
};
|
|
}
|