--- import DashboardLayout from '../../layouts/DashboardLayout.astro'; import { Icon } from 'astro-icon/components'; import CategoryChart from '../../components/CategoryChart.vue'; import ClientChart from '../../components/ClientChart.vue'; import MemberChart from '../../components/MemberChart.vue'; import { db } from '../../db'; import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema'; import { eq, and, gte, lte, sql, desc } from 'drizzle-orm'; import { formatDuration, formatTimeRange } from '../../lib/formatTime'; const user = Astro.locals.user; if (!user) return Astro.redirect('/login'); // Get current team from cookie const currentTeamId = Astro.cookies.get('currentTeamId')?.value; const userMemberships = await db.select() .from(members) .where(eq(members.userId, user.id)) .all(); if (userMemberships.length === 0) return Astro.redirect('/dashboard'); // Use current team or fallback to first membership const userMembership = currentTeamId ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] : userMemberships[0]; const teamMembers = await db.select({ id: users.id, name: users.name, email: users.email, }) .from(members) .innerJoin(users, eq(members.userId, users.id)) .where(eq(members.organizationId, userMembership.organizationId)) .all(); const allCategories = await db.select() .from(categories) .where(eq(categories.organizationId, userMembership.organizationId)) .all(); const allClients = await db.select() .from(clients) .where(eq(clients.organizationId, userMembership.organizationId)) .all(); const url = new URL(Astro.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(); const statsByMember = teamMembers.map(member => { const memberEntries = entries.filter(e => e.user.id === member.id); const totalTime = memberEntries.reduce((sum, e) => { if (e.entry.endTime) { return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime()); } return sum; }, 0); return { member, totalTime, entryCount: memberEntries.length, }; }).sort((a, b) => b.totalTime - a.totalTime); const statsByCategory = allCategories.map(category => { const categoryEntries = entries.filter(e => e.category.id === category.id); const totalTime = categoryEntries.reduce((sum, e) => { if (e.entry.endTime) { return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime()); } return sum; }, 0); return { category, totalTime, entryCount: categoryEntries.length, }; }).sort((a, b) => b.totalTime - a.totalTime); const statsByClient = allClients.map(client => { const clientEntries = entries.filter(e => e.client.id === client.id); const totalTime = clientEntries.reduce((sum, e) => { if (e.entry.endTime) { return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime()); } return sum; }, 0); return { client, totalTime, entryCount: clientEntries.length, }; }).sort((a, b) => b.totalTime - a.totalTime); const totalTime = entries.reduce((sum, e) => { if (e.entry.endTime) { return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime()); } return sum; }, 0); // Fetch invoices and quotes for the same time period const invoiceConditions = [ eq(invoices.organizationId, userMembership.organizationId), gte(invoices.issueDate, startDate), lte(invoices.issueDate, endDate), ]; if (selectedClientId) { invoiceConditions.push(eq(invoices.clientId, selectedClientId)); } const allInvoices = await db.select({ invoice: invoices, client: clients, }) .from(invoices) .leftJoin(clients, eq(invoices.clientId, clients.id)) .where(and(...invoiceConditions)) .orderBy(desc(invoices.issueDate)) .all(); // Invoice statistics const invoiceStats = { total: allInvoices.filter(i => i.invoice.type === 'invoice').length, paid: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid').length, sent: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent').length, draft: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'draft').length, void: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'void').length, }; // Quote statistics const quoteStats = { total: allInvoices.filter(i => i.invoice.type === 'quote').length, accepted: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'accepted').length, sent: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length, declined: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'declined').length, draft: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'draft').length, }; // Revenue statistics const revenueStats = { total: allInvoices .filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid') .reduce((sum, i) => sum + i.invoice.total, 0), pending: allInvoices .filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent') .reduce((sum, i) => sum + i.invoice.total, 0), quotedValue: allInvoices .filter(i => i.invoice.type === 'quote' && (i.invoice.status === 'sent' || i.invoice.status === 'accepted')) .reduce((sum, i) => sum + i.invoice.total, 0), }; // Revenue by client const revenueByClient = allClients.map(client => { const clientInvoices = allInvoices.filter(i => i.client?.id === client.id && i.invoice.type === 'invoice' && i.invoice.status === 'paid' ); const revenue = clientInvoices.reduce((sum, i) => sum + i.invoice.total, 0); return { client, revenue, invoiceCount: clientInvoices.length, }; }).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue); function formatCurrency(amount: number, currency: string = 'USD') { return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency, }).format(amount / 100); } function getTimeRangeLabel(range: string) { switch (range) { case 'today': return 'Today'; case 'week': return 'Last 7 Days'; case 'month': return 'Last 30 Days'; 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'; } } ---

Team Reports

{timeRange === 'custom' && ( <>
)}
Total Time
{formatDuration(totalTime)}
{getTimeRangeLabel(timeRange)}
Total Entries
{entries.length}
{getTimeRangeLabel(timeRange)}
Revenue
{formatCurrency(revenueStats.total)}
{invoiceStats.paid} paid invoices
Active Members
{statsByMember.filter(s => s.entryCount > 0).length}
of {teamMembers.length} total

Invoices Overview

Total Invoices
{invoiceStats.total}
Paid
{invoiceStats.paid}
Sent
{invoiceStats.sent}
Draft
{invoiceStats.draft}
Revenue (Paid) {formatCurrency(revenueStats.total)}
Pending (Sent) {formatCurrency(revenueStats.pending)}

Quotes Overview

Total Quotes
{quoteStats.total}
Accepted
{quoteStats.accepted}
Pending
{quoteStats.sent}
Declined
{quoteStats.declined}
Quoted Value {formatCurrency(revenueStats.quotedValue)}
Conversion Rate {quoteStats.total > 0 ? Math.round((quoteStats.accepted / quoteStats.total) * 100) : 0}%
{!selectedClientId && revenueByClient.length > 0 && (

Revenue by Client

{revenueByClient.slice(0, 10).map(stat => ( ))}
Client Revenue Invoices Avg Invoice
{stat.client.name}
{formatCurrency(stat.revenue)} {stat.invoiceCount} {stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
)} {/* Charts Section - Only show if there's data */} {totalTime > 0 && ( <>
{/* Category Distribution Chart - Only show when no category filter */} {!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (

Category Distribution

s.totalTime > 0).map(s => ({ name: s.category.name, totalTime: s.totalTime, color: s.category.color || '#3b82f6' }))} />
)} {/* Client Distribution Chart - Only show when no client filter */} {!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (

Time by Client

s.totalTime > 0).map(s => ({ name: s.client.name, totalTime: s.totalTime }))} />
)}
{/* Team Member Chart - Only show when no member filter */} {!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (

Time by Team Member

s.totalTime > 0).map(s => ({ name: s.member.name, totalTime: s.totalTime }))} />
)} )} {/* Stats by Member - Only show if there's data and no member filter */} {!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (

By Team Member

{statsByMember.filter(s => s.totalTime > 0).map(stat => ( ))}
Member Total Time Entries Avg per Entry
{stat.member.name}
{stat.member.email}
{formatDuration(stat.totalTime)} {stat.entryCount} {stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
)} {/* Stats by Category - Only show if there's data and no category filter */} {!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (

By Category

{statsByCategory.filter(s => s.totalTime > 0).map(stat => ( ))}
Category Total Time Entries % of Total
{stat.category.color && ( )} {stat.category.name}
{formatDuration(stat.totalTime)} {stat.entryCount}
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
)} {/* Stats by Client - Only show if there's data and no client filter */} {!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (

By Client

{statsByClient.filter(s => s.totalTime > 0).map(stat => ( ))}
Client Total Time Entries % of Total
{stat.client.name} {formatDuration(stat.totalTime)} {stat.entryCount}
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
)} {/* Detailed Entries */}

Detailed Entries ({entries.length})

{entries.length > 0 && ( Export CSV )}
{entries.length > 0 ? (
{entries.map(e => ( ))}
Date Member Client Category Description Duration
{e.entry.startTime.toLocaleDateString()}
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
{e.user.name} {e.client.name}
{e.category.color && ( )} {e.category.name}
{e.entry.description || '-'} {e.entry.endTime ? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime()) : 'Running...' }
) : (

No time entries found

Try adjusting your filters or select a different time range.

Start Tracking Time
)}