First pass
This commit is contained in:
539
src/pages/dashboard/reports.astro
Normal file
539
src/pages/dashboard/reports.astro
Normal file
@@ -0,0 +1,539 @@
|
||||
---
|
||||
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 } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's organization
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
// Get all team members
|
||||
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();
|
||||
|
||||
// Get all categories
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
// Get all clients
|
||||
const allClients = await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
// Parse filter parameters
|
||||
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';
|
||||
|
||||
// Calculate date range
|
||||
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;
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
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));
|
||||
}
|
||||
|
||||
// Fetch detailed entries
|
||||
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();
|
||||
|
||||
// Calculate statistics by member
|
||||
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);
|
||||
|
||||
// Calculate statistics by category
|
||||
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);
|
||||
|
||||
// Calculate statistics by client
|
||||
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);
|
||||
|
||||
// Calculate total time
|
||||
const totalTime = entries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
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';
|
||||
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';
|
||||
default: return 'Last 7 Days';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<DashboardLayout title="Reports - Zamaan">
|
||||
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Time Range</span>
|
||||
</label>
|
||||
<select name="range" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="today" selected={timeRange === 'today'}>Today</option>
|
||||
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
|
||||
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Team Member</span>
|
||||
</label>
|
||||
<select name="member" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Members</option>
|
||||
{teamMembers.map(member => (
|
||||
<option value={member.id} selected={selectedMemberId === member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={selectedCategoryId === category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Clients</option>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={selectedClientId === client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Time</div>
|
||||
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
|
||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Entries</div>
|
||||
<div class="stat-value text-secondary">{entries.length}</div>
|
||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-accent">
|
||||
<Icon name="heroicons:user-group" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Active Members</div>
|
||||
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
|
||||
<div class="stat-desc">of {teamMembers.length} total</div>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<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 -->
|
||||
<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">
|
||||
<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 -->
|
||||
<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 => (
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 -->
|
||||
<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 => (
|
||||
<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>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
Reference in New Issue
Block a user