Add comprehensive admin features and multi-system integration
- 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
This commit is contained in:
parent
e8462ef301
commit
6eee14f8af
171 changed files with 32671 additions and 621 deletions
313
app/api/data/time-entries/route.ts
Normal file
313
app/api/data/time-entries/route.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Pool } from 'pg';
|
||||
import { TimeEntry } from '@/lib/types/database';
|
||||
|
||||
// Initialize PostgreSQL connection
|
||||
const pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST,
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
database: process.env.POSTGRES_DB || 'pulse_autotask',
|
||||
user: process.env.POSTGRES_USER,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
ssl: process.env.POSTGRES_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// Parse query parameters
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const search = searchParams.get('search') || '';
|
||||
const resourceId = searchParams.get('resource_id');
|
||||
const ticketId = searchParams.get('ticket_id');
|
||||
const taskId = searchParams.get('task_id');
|
||||
const projectId = searchParams.get('project_id');
|
||||
const companyId = searchParams.get('company_id');
|
||||
const startDate = searchParams.get('start_date');
|
||||
const endDate = searchParams.get('end_date');
|
||||
const sortBy = searchParams.get('sort_by') || 'entry_date';
|
||||
const sortOrder = searchParams.get('sort_order') || 'desc';
|
||||
const minHours = searchParams.get('min_hours');
|
||||
const maxHours = searchParams.get('max_hours');
|
||||
const billable = searchParams.get('billable');
|
||||
const approved = searchParams.get('approved');
|
||||
const hasTicket = searchParams.get('has_ticket');
|
||||
|
||||
// Build WHERE conditions
|
||||
const conditions: string[] = ['te.is_deleted = false'];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Add search condition (search in notes, title, internal_notes)
|
||||
if (search) {
|
||||
conditions.push(`(
|
||||
te.notes ILIKE $${paramIndex} OR
|
||||
te.title ILIKE $${paramIndex} OR
|
||||
te.internal_notes ILIKE $${paramIndex}
|
||||
)`);
|
||||
params.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Add filter conditions
|
||||
if (resourceId) {
|
||||
conditions.push(`te.resource_id = $${paramIndex}`);
|
||||
params.push(resourceId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (ticketId) {
|
||||
conditions.push(`te.ticket_id = $${paramIndex}`);
|
||||
params.push(ticketId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
conditions.push(`te.task_id = $${paramIndex}`);
|
||||
params.push(taskId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
conditions.push(`te.project_id = $${paramIndex}`);
|
||||
params.push(projectId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (companyId) {
|
||||
conditions.push(`te.company_id = $${paramIndex}`);
|
||||
params.push(companyId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`te.entry_date >= $${paramIndex}`);
|
||||
params.push(startDate);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`te.entry_date <= $${paramIndex}`);
|
||||
params.push(endDate);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (minHours) {
|
||||
conditions.push(`te.hours_worked >= $${paramIndex}`);
|
||||
params.push(minHours);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (maxHours) {
|
||||
conditions.push(`te.hours_worked <= $${paramIndex}`);
|
||||
params.push(maxHours);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (billable !== null && billable !== undefined) {
|
||||
conditions.push(`te.billable = $${paramIndex}`);
|
||||
params.push(billable === 'true');
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (approved !== null && approved !== undefined) {
|
||||
conditions.push(`te.approved = $${paramIndex}`);
|
||||
params.push(approved === 'true');
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (hasTicket === 'true') {
|
||||
conditions.push(`te.ticket_id IS NOT NULL`);
|
||||
}
|
||||
|
||||
// Validate sort column
|
||||
const validSortColumns = [
|
||||
'entry_date', 'hours_worked', 'created_at', 'updated_at',
|
||||
'resource_id', 'ticket_id', 'task_id', 'project_id', 'company_id',
|
||||
'title', 'billable', 'approved'
|
||||
];
|
||||
const validSortBy = validSortColumns.includes(sortBy) ? sortBy : 'entry_date';
|
||||
const validSortOrder = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Build the main query
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
te.id,
|
||||
te.resource_id,
|
||||
r.first_name || ' ' || r.last_name as resource_name,
|
||||
te.ticket_id,
|
||||
t.ticket_number,
|
||||
t.title as ticket_title,
|
||||
te.task_id,
|
||||
task.title as task_title,
|
||||
te.project_id,
|
||||
p.project_name,
|
||||
te.company_id,
|
||||
c.company_name,
|
||||
te.entry_date,
|
||||
te.hours_worked,
|
||||
te.notes,
|
||||
te.internal_notes,
|
||||
te.title,
|
||||
te.type,
|
||||
te.start_date_time,
|
||||
te.end_date_time,
|
||||
te.billable,
|
||||
te.billing_rate,
|
||||
te.approved,
|
||||
te.approved_date_time,
|
||||
te.non_billable,
|
||||
te.created_at,
|
||||
te.updated_at,
|
||||
te.synced_at
|
||||
FROM time_entries te
|
||||
LEFT JOIN resources r ON te.resource_id = r.id
|
||||
LEFT JOIN tickets t ON te.ticket_id = t.id
|
||||
LEFT JOIN tasks task ON te.task_id = task.id
|
||||
LEFT JOIN projects p ON te.project_id = p.id
|
||||
LEFT JOIN companies c ON te.company_id = c.id
|
||||
${whereClause}
|
||||
ORDER BY te.${validSortBy} ${validSortOrder}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
paramIndex += 2;
|
||||
|
||||
// Get total count
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM time_entries te
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
// Execute both queries in parallel
|
||||
const [result, countResult] = await Promise.all([
|
||||
client.query(query, params),
|
||||
client.query(countQuery, params.slice(0, -2)) // Remove limit and offset for count
|
||||
]);
|
||||
|
||||
const timeEntries: TimeEntry[] = result.rows;
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
// Get summary statistics
|
||||
const summaryQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_entries,
|
||||
SUM(hours_worked) as total_hours,
|
||||
AVG(hours_worked) as avg_hours,
|
||||
MIN(entry_date) as earliest_date,
|
||||
MAX(entry_date) as latest_date,
|
||||
COUNT(CASE WHEN billable = true THEN 1 END) as billable_entries,
|
||||
COUNT(CASE WHEN approved = true THEN 1 END) as approved_entries
|
||||
FROM time_entries te
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const summaryResult = await client.query(summaryQuery, params.slice(0, -2));
|
||||
const summary = summaryResult.rows[0];
|
||||
|
||||
return NextResponse.json({
|
||||
timeEntries,
|
||||
pagination: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
},
|
||||
summary: {
|
||||
totalEntries: parseInt(summary.total_entries),
|
||||
totalHours: parseFloat(summary.total_hours) || 0,
|
||||
averageHours: parseFloat(summary.avg_hours) || 0,
|
||||
earliestDate: summary.earliest_date,
|
||||
latestDate: summary.latest_date,
|
||||
billableEntries: parseInt(summary.billable_entries),
|
||||
approvedEntries: parseInt(summary.approved_entries),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching time entries:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch time entries' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = ['resource_id', 'entry_date', 'hours_worked'];
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required field: ${field}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO time_entries (
|
||||
resource_id, ticket_id, task_id, project_id, company_id,
|
||||
entry_date, hours_worked, notes, internal_notes, title,
|
||||
type, start_date_time, end_date_time, billable,
|
||||
billing_rate, approved, approved_date_time, non_billable,
|
||||
created_at, updated_at, synced_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
body.resource_id,
|
||||
body.ticket_id || null,
|
||||
body.task_id || null,
|
||||
body.project_id || null,
|
||||
body.company_id || null,
|
||||
body.entry_date,
|
||||
body.hours_worked,
|
||||
body.notes || null,
|
||||
body.internal_notes || null,
|
||||
body.title || null,
|
||||
body.type || null,
|
||||
body.start_date_time || null,
|
||||
body.end_date_time || null,
|
||||
body.billable !== undefined ? body.billable : true,
|
||||
body.billing_rate || null,
|
||||
body.approved !== undefined ? body.approved : false,
|
||||
body.approved_date_time || null,
|
||||
body.non_billable !== undefined ? body.non_billable : false,
|
||||
];
|
||||
|
||||
const result = await client.query(query, values);
|
||||
const timeEntry: TimeEntry = result.rows[0];
|
||||
|
||||
return NextResponse.json({ timeEntry }, { status: 201 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating time entry:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create time entry' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue