Added custom ranges for report filtering + CSV exports
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s

This commit is contained in:
2026-01-19 15:18:34 -07:00
parent 1063bf99f1
commit bf2a1816db
3 changed files with 194 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "2.1.0",
"version": "2.2.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",

View File

@@ -0,0 +1,137 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, users, clients, categories } from '../../../db/schema';
import { eq, and, gte, lte, desc } from 'drizzle-orm';
export const GET: APIRoute = async ({ request, locals, cookies }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Get current team from cookie
const currentTeamId = cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) {
return new Response('No organization found', { status: 404 });
}
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const url = new URL(request.url);
const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const now = new Date();
let startDate = new Date();
let endDate = new Date();
switch (timeRange) {
case 'today':
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
break;
case 'week':
startDate.setDate(now.getDate() - 7);
break;
case 'month':
startDate.setMonth(now.getMonth() - 1);
break;
case 'mtd':
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'ytd':
startDate = new Date(now.getFullYear(), 0, 1);
break;
case 'last-month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case 'custom':
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
}
break;
}
const conditions = [
eq(timeEntries.organizationId, userMembership.organizationId),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate),
];
if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
// Generate CSV
const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description'];
const rows = entries.map(e => {
const start = e.entry.startTime;
const end = e.entry.endTime;
let duration = 0;
if (end) {
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
}
return [
start.toLocaleDateString(),
start.toLocaleTimeString(),
end ? end.toLocaleTimeString() : '',
end ? duration.toFixed(2) : 'Running',
`"${(e.user.name || '').replace(/"/g, '""')}"`,
`"${(e.client.name || '').replace(/"/g, '""')}"`,
`"${(e.category.name || '').replace(/"/g, '""')}"`,
`"${(e.entry.description || '').replace(/"/g, '""')}"`
].join(',');
});
const csvContent = [headers.join(','), ...rows].join('\n');
return new Response(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.csv"`,
},
});
};

View File

@@ -52,6 +52,8 @@ const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const now = new Date();
let startDate = new Date();
@@ -78,6 +80,16 @@ switch (timeRange) {
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case 'custom':
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
}
break;
}
const conditions = [
@@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) {
case 'mtd': return 'Month to Date';
case 'ytd': return 'Year to Date';
case 'last-month': return 'Last Month';
case 'custom': return 'Custom Range';
default: return 'Last 7 Days';
}
}
@@ -273,9 +286,39 @@ function getTimeRangeLabel(range: string) {
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select>
</div>
{timeRange === 'custom' && (
<>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">From Date</span>
</label>
<input
type="date"
name="from"
class="input input-bordered w-full"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">To Date</span>
</label>
<input
type="date"
name="to"
class="input input-bordered w-full"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
</div>
</>
)}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Team Member</span>
@@ -328,7 +371,7 @@ function getTimeRangeLabel(range: string) {
width: 100%;
}
}
select {
select, input {
width: 100%;
}
</style>
@@ -709,10 +752,18 @@ function getTimeRangeLabel(range: string) {
{/* Detailed Entries */}
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:document-text" class="w-6 h-6" />
Detailed Entries ({entries.length})
</h2>
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:document-text" class="w-6 h-6" />
Detailed Entries ({entries.length})
</h2>
{entries.length > 0 && (
<a href={`/api/reports/export${url.search}`} class="btn btn-sm btn-outline" target="_blank">
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
Export CSV
</a>
)}
</div>
{entries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table table-zebra">