314 lines
9.4 KiB
TypeScript
314 lines
9.4 KiB
TypeScript
|
|
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 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|