This commit is contained in:
2025-12-26 17:55:00 -07:00
parent ae1fb10898
commit 0140c5b39b
35 changed files with 1160 additions and 513 deletions

View File

@@ -7,16 +7,25 @@ import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, categories } 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');
const userMembership = await db.select()
// 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))
.get();
.all();
if (!userMembership) return Astro.redirect('/dashboard');
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,
@@ -158,12 +167,6 @@ const totalTime = entries.reduce((sum, e) => {
return sum;
}, 0);
function formatDuration(ms: number) {
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
return `${hours}h ${minutes}m`;
}
function getTimeRangeLabel(range: string) {
switch (range) {
case 'today': return 'Today';
@@ -177,7 +180,7 @@ function getTimeRangeLabel(range: string) {
}
---
<DashboardLayout title="Reports - Zamaan">
<DashboardLayout title="Reports - Chronus">
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
<!-- Filters -->
@@ -279,249 +282,277 @@ function getTimeRangeLabel(range: string) {
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Category Distribution Chart -->
<div class="card bg-base-100 shadow-xl border border-base-200">
{/* Charts Section - Only show if there's data */}
{totalTime > 0 && (
<>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Category Distribution Chart - Only show when no category filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
<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:chart-pie" class="w-6 h-6" />
Category Distribution
</h2>
<div class="h-64 w-full">
<CategoryChart
client:load
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
totalTime: s.totalTime,
color: s.category.color || '#3b82f6'
}))}
/>
</div>
</div>
</div>
)}
{/* Client Distribution Chart - Only show when no client filter */}
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
<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:chart-bar" class="w-6 h-6" />
Time by Client
</h2>
<div class="h-64 w-full">
<ClientChart
client:load
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
name: s.client.name,
totalTime: s.totalTime
}))}
/>
</div>
</div>
</div>
)}
</div>
{/* Team Member Chart - Only show when no member filter */}
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:users" class="w-6 h-6" />
Time by Team Member
</h2>
<div class="h-64 w-full">
<MemberChart
client:load
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
name: s.member.name,
totalTime: s.totalTime
}))}
/>
</div>
</div>
</div>
)}
</>
)}
{/* Stats by Member - Only show if there's data and no member filter */}
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
Category Distribution
<Icon name="heroicons:users" class="w-6 h-6" />
By Team Member
</h2>
<div class="h-64">
<CategoryChart
client:load
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
totalTime: s.totalTime,
color: s.category.color || '#3b82f6'
}))}
/>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Member</th>
<th>Total Time</th>
<th>Entries</th>
<th>Avg per Entry</th>
</tr>
</thead>
<tbody>
{statsByMember.filter(s => s.totalTime > 0).map(stat => (
<tr>
<td>
<div>
<div class="font-bold">{stat.member.name}</div>
<div class="text-sm opacity-50">{stat.member.email}</div>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td class="font-mono">
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
<!-- Client Distribution Chart -->
<div class="card bg-base-100 shadow-xl border border-base-200">
{/* Stats by Category - Only show if there's data and no category filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:chart-bar" class="w-6 h-6" />
Time by Client
<Icon name="heroicons:tag" class="w-6 h-6" />
By Category
</h2>
<div class="h-64">
<ClientChart
client:load
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
name: s.client.name,
totalTime: s.totalTime
}))}
/>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Category</th>
<th>Total Time</th>
<th>Entries</th>
<th>% of Total</th>
</tr>
</thead>
<tbody>
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
<tr>
<td>
<div class="flex items-center gap-2">
{stat.category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
)}
<span>{stat.category.name}</span>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-primary w-20"
value={stat.totalTime}
max={totalTime}
></progress>
<span class="text-sm">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
<!-- Team Member Chart -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:users" class="w-6 h-6" />
Time by Team Member
</h2>
<div class="h-64">
<MemberChart
client:load
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
name: s.member.name,
totalTime: s.totalTime
}))}
/>
</div>
</div>
</div>
<!-- Stats by Member -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:users" class="w-6 h-6" />
By Team Member
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Member</th>
<th>Total Time</th>
<th>Entries</th>
<th>Avg per Entry</th>
</tr>
</thead>
<tbody>
{statsByMember.map(stat => (
{/* Stats by Client - Only show if there's data and no client filter */}
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:building-office" class="w-6 h-6" />
By Client
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<td>
<div>
<div class="font-bold">{stat.member.name}</div>
<div class="text-sm opacity-50">{stat.member.email}</div>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td class="font-mono">
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '0h 0m'}
</td>
<th>Client</th>
<th>Total Time</th>
<th>Entries</th>
<th>% of Total</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{statsByClient.filter(s => s.totalTime > 0).map(stat => (
<tr>
<td>{stat.client.name}</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-secondary w-20"
value={stat.totalTime}
max={totalTime}
></progress>
<span class="text-sm">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
<!-- Stats by Category -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:tag" class="w-6 h-6" />
By Category
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Category</th>
<th>Total Time</th>
<th>Entries</th>
<th>% of Total</th>
</tr>
</thead>
<tbody>
{statsByCategory.map(stat => (
<tr>
<td>
<div class="flex items-center gap-2">
{stat.category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
)}
<span>{stat.category.name}</span>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-primary w-20"
value={stat.totalTime}
max={totalTime}
></progress>
<span class="text-sm">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<!-- Stats by Client -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:building-office" class="w-6 h-6" />
By Client
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Client</th>
<th>Total Time</th>
<th>Entries</th>
<th>% of Total</th>
</tr>
</thead>
<tbody>
{statsByClient.map(stat => (
<tr>
<td>{stat.client.name}</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-secondary w-20"
value={stat.totalTime}
max={totalTime}
></progress>
<span class="text-sm">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<!-- Detailed Entries -->
{/* 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="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Date</th>
<th>Member</th>
<th>Client</th>
<th>Category</th>
<th>Description</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{entries.map(e => (
{entries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<td class="whitespace-nowrap">
{e.entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50">
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</td>
<td>{e.user.name}</td>
<td>{e.client.name}</td>
<td>
<div class="flex items-center gap-2">
{e.category.color && (
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
)}
<span>{e.category.name}</span>
</div>
</td>
<td>{e.entry.description || '-'}</td>
<td class="font-mono">
{e.entry.endTime
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
: 'Running...'
}
</td>
<th>Date</th>
<th>Member</th>
<th>Client</th>
<th>Category</th>
<th>Description</th>
<th>Duration</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{entries.map(e => (
<tr>
<td class="whitespace-nowrap">
{e.entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50">
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</td>
<td>{e.user.name}</td>
<td>{e.client.name}</td>
<td>
<div class="flex items-center gap-2">
{e.category.color && (
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
)}
<span>{e.category.name}</span>
</div>
</td>
<td>{e.entry.description || '-'}</td>
<td class="font-mono">
{e.entry.endTime
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
: 'Running...'
}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div class="flex flex-col items-center justify-center py-12 text-center">
<Icon name="heroicons:inbox" class="w-16 h-16 text-base-content/20 mb-4" />
<h3 class="text-lg font-semibold mb-2">No time entries found</h3>
<p class="text-base-content/60 mb-4">Try adjusting your filters or select a different time range.</p>
<a href="/dashboard/tracker" class="btn btn-primary">
<Icon name="heroicons:play" class="w-5 h-5" />
Start Tracking Time
</a>
</div>
)}
</div>
</div>
</DashboardLayout>