Added custom ranges for report filtering + CSV exports
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
|
|||||||
137
src/pages/api/reports/export.ts
Normal file
137
src/pages/api/reports/export.ts
Normal 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"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -52,6 +52,8 @@ const selectedMemberId = url.searchParams.get('member') || '';
|
|||||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
const selectedCategoryId = url.searchParams.get('category') || '';
|
||||||
const selectedClientId = url.searchParams.get('client') || '';
|
const selectedClientId = url.searchParams.get('client') || '';
|
||||||
const timeRange = url.searchParams.get('range') || 'week';
|
const timeRange = url.searchParams.get('range') || 'week';
|
||||||
|
const customFrom = url.searchParams.get('from');
|
||||||
|
const customTo = url.searchParams.get('to');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let startDate = new Date();
|
let startDate = new Date();
|
||||||
@@ -78,6 +80,16 @@ switch (timeRange) {
|
|||||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||||
break;
|
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 = [
|
const conditions = [
|
||||||
@@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
case 'mtd': return 'Month to Date';
|
case 'mtd': return 'Month to Date';
|
||||||
case 'ytd': return 'Year to Date';
|
case 'ytd': return 'Year to Date';
|
||||||
case 'last-month': return 'Last Month';
|
case 'last-month': return 'Last Month';
|
||||||
|
case 'custom': return 'Custom Range';
|
||||||
default: return 'Last 7 Days';
|
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="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
|
||||||
<option value="ytd" selected={timeRange === 'ytd'}>Year 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="last-month" selected={timeRange === 'last-month'}>Last Month</option>
|
||||||
|
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Team Member</span>
|
<span class="label-text font-medium">Team Member</span>
|
||||||
@@ -328,7 +371,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
select {
|
select, input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -709,10 +752,18 @@ function getTimeRangeLabel(range: string) {
|
|||||||
{/* Detailed Entries */}
|
{/* Detailed Entries */}
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
<h2 class="card-title">
|
||||||
Detailed Entries ({entries.length})
|
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||||
</h2>
|
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 ? (
|
{entries.length > 0 ? (
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
|
|||||||
Reference in New Issue
Block a user