All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
869 lines
31 KiB
Plaintext
869 lines
31 KiB
Plaintext
---
|
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
|
import { Icon } from 'astro-icon/components';
|
|
import TagChart from '../../components/TagChart.vue';
|
|
import ClientChart from '../../components/ClientChart.vue';
|
|
import MemberChart from '../../components/MemberChart.vue';
|
|
import { db } from '../../db';
|
|
import { timeEntries, members, users, clients, tags, timeEntryTags, invoices } from '../../db/schema';
|
|
import { eq, and, gte, lte, sql, desc, inArray, exists } 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 allTags = await db.select()
|
|
.from(tags)
|
|
.where(eq(tags.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 selectedTagId = url.searchParams.get('tag') || '';
|
|
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 (selectedTagId) {
|
|
conditions.push(exists(
|
|
db.select()
|
|
.from(timeEntryTags)
|
|
.where(and(
|
|
eq(timeEntryTags.timeEntryId, timeEntries.id),
|
|
eq(timeEntryTags.tagId, selectedTagId)
|
|
))
|
|
));
|
|
}
|
|
|
|
if (selectedClientId) {
|
|
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
|
}
|
|
|
|
const entriesData = await db.select({
|
|
entry: timeEntries,
|
|
user: users,
|
|
client: clients,
|
|
})
|
|
.from(timeEntries)
|
|
.innerJoin(users, eq(timeEntries.userId, users.id))
|
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
|
.where(and(...conditions))
|
|
.orderBy(desc(timeEntries.startTime))
|
|
.all();
|
|
|
|
// Fetch tags for these entries
|
|
const entryIds = entriesData.map(e => e.entry.id);
|
|
const tagsMap = new Map<string, typeof allTags>();
|
|
|
|
if (entryIds.length > 0) {
|
|
// Process in chunks if too many entries, but for now simple inArray
|
|
// Sqlite has limits on variables, but usually ~999. Assuming reasonable page size or volume.
|
|
// If entryIds is massive, this might fail, but for a dashboard report it's usually acceptable or needs pagination/limits.
|
|
// However, `inArray` can be empty, so we checked length.
|
|
|
|
const entryTagsData = await db.select({
|
|
timeEntryId: timeEntryTags.timeEntryId,
|
|
tag: tags
|
|
})
|
|
.from(timeEntryTags)
|
|
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
|
|
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
.all();
|
|
|
|
for (const item of entryTagsData) {
|
|
if (!tagsMap.has(item.timeEntryId)) {
|
|
tagsMap.set(item.timeEntryId, []);
|
|
}
|
|
tagsMap.get(item.timeEntryId)!.push(item.tag);
|
|
}
|
|
}
|
|
|
|
const entries = entriesData.map(e => ({
|
|
...e,
|
|
tags: tagsMap.get(e.entry.id) || []
|
|
}));
|
|
|
|
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 statsByTag = allTags.map(tag => {
|
|
const tagEntries = entries.filter(e => e.tags.some(t => t.id === tag.id));
|
|
const totalTime = tagEntries.reduce((sum, e) => {
|
|
if (e.entry.endTime) {
|
|
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
|
}
|
|
return sum;
|
|
}, 0);
|
|
|
|
return {
|
|
tag,
|
|
totalTime,
|
|
entryCount: tagEntries.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';
|
|
}
|
|
}
|
|
---
|
|
|
|
<DashboardLayout title="Reports - Chronus">
|
|
<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 font-medium" for="reports-range">
|
|
Time Range
|
|
</label>
|
|
<select id="reports-range" 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>
|
|
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
|
|
</select>
|
|
</div>
|
|
|
|
{timeRange === 'custom' && (
|
|
<>
|
|
<div class="form-control">
|
|
<label class="label font-medium" for="reports-from">
|
|
From Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
id="reports-from"
|
|
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 font-medium" for="reports-to">
|
|
To Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
id="reports-to"
|
|
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 font-medium" for="reports-member">
|
|
Team Member
|
|
</label>
|
|
<select id="reports-member" 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 font-medium" for="reports-tag">
|
|
Tag
|
|
</label>
|
|
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()">
|
|
<option value="">All Tags</option>
|
|
{allTags.map(tag => (
|
|
<option value={tag.id} selected={selectedTagId === tag.id}>
|
|
{tag.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label font-medium" for="reports-client">
|
|
Client
|
|
</label>
|
|
<select id="reports-client" 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>
|
|
|
|
<style>
|
|
@media (max-width: 767px) {
|
|
form {
|
|
align-items: stretch !important;
|
|
}
|
|
.form-control {
|
|
width: 100%;
|
|
}
|
|
}
|
|
select, input {
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Stats -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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-success">
|
|
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
|
</div>
|
|
<div class="stat-title">Revenue</div>
|
|
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
|
|
<div class="stat-desc">{invoiceStats.paid} paid invoices</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>
|
|
|
|
<!-- Invoice & Quote Stats -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<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" />
|
|
Invoices Overview
|
|
</h2>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="stat bg-base-200 rounded-lg">
|
|
<div class="stat-title text-xs">Total Invoices</div>
|
|
<div class="stat-value text-2xl">{invoiceStats.total}</div>
|
|
</div>
|
|
<div class="stat bg-success/10 rounded-lg">
|
|
<div class="stat-title text-xs">Paid</div>
|
|
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
|
|
</div>
|
|
<div class="stat bg-info/10 rounded-lg">
|
|
<div class="stat-title text-xs">Sent</div>
|
|
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
|
|
</div>
|
|
<div class="stat bg-base-200 rounded-lg">
|
|
<div class="stat-title text-xs">Draft</div>
|
|
<div class="stat-value text-2xl">{invoiceStats.draft}</div>
|
|
</div>
|
|
</div>
|
|
<div class="divider my-2"></div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-base-content/60">Revenue (Paid)</span>
|
|
<span class="font-bold text-success">{formatCurrency(revenueStats.total)}</span>
|
|
</div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-base-content/60">Pending (Sent)</span>
|
|
<span class="font-bold text-warning">{formatCurrency(revenueStats.pending)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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:clipboard-document-list" class="w-6 h-6" />
|
|
Quotes Overview
|
|
</h2>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="stat bg-base-200 rounded-lg">
|
|
<div class="stat-title text-xs">Total Quotes</div>
|
|
<div class="stat-value text-2xl">{quoteStats.total}</div>
|
|
</div>
|
|
<div class="stat bg-success/10 rounded-lg">
|
|
<div class="stat-title text-xs">Accepted</div>
|
|
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
|
|
</div>
|
|
<div class="stat bg-info/10 rounded-lg">
|
|
<div class="stat-title text-xs">Pending</div>
|
|
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
|
|
</div>
|
|
<div class="stat bg-error/10 rounded-lg">
|
|
<div class="stat-title text-xs">Declined</div>
|
|
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
|
|
</div>
|
|
</div>
|
|
<div class="divider my-2"></div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-base-content/60">Quoted Value</span>
|
|
<span class="font-bold">{formatCurrency(revenueStats.quotedValue)}</span>
|
|
</div>
|
|
<div class="flex justify-between text-sm">
|
|
<span class="text-base-content/60">Conversion Rate</span>
|
|
<span class="font-bold">
|
|
{quoteStats.total > 0 ? Math.round((quoteStats.accepted / quoteStats.total) * 100) : 0}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
|
|
{!selectedClientId && revenueByClient.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:banknotes" class="w-6 h-6" />
|
|
Revenue by Client
|
|
</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Client</th>
|
|
<th>Revenue</th>
|
|
<th>Invoices</th>
|
|
<th>Avg Invoice</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{revenueByClient.slice(0, 10).map(stat => (
|
|
<tr>
|
|
<td>
|
|
<div class="font-bold">{stat.client.name}</div>
|
|
</td>
|
|
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td>
|
|
<td>{stat.invoiceCount}</td>
|
|
<td class="font-mono">
|
|
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Charts Section - Only show if there's data */}
|
|
{totalTime > 0 && (
|
|
<>
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
{/* Tag Distribution Chart - Only show when no tag filter */}
|
|
{!selectedTagId && statsByTag.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" />
|
|
Tag Distribution
|
|
</h2>
|
|
<div class="h-64 w-full">
|
|
<TagChart
|
|
client:visible
|
|
tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
|
|
name: s.tag.name,
|
|
totalTime: s.totalTime,
|
|
color: s.tag.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:visible
|
|
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:visible
|
|
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: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.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>
|
|
)}
|
|
|
|
{/* Stats by Tag - Only show if there's data and no tag filter */}
|
|
{!selectedTagId && statsByTag.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:tag" class="w-6 h-6" />
|
|
By Tag
|
|
</h2>
|
|
<div class="overflow-x-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Tag</th>
|
|
<th>Total Time</th>
|
|
<th>Entries</th>
|
|
<th>% of Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{statsByTag.filter(s => s.totalTime > 0).map(stat => (
|
|
<tr>
|
|
<td>
|
|
<div class="flex items-center gap-2">
|
|
{stat.tag.color && (
|
|
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
|
|
)}
|
|
<span>{stat.tag.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 - 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>
|
|
<th>Client</th>
|
|
<th>Total Time</th>
|
|
<th>Entries</th>
|
|
<th>% of Total</th>
|
|
</tr>
|
|
</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>
|
|
)}
|
|
|
|
{/* Detailed Entries */}
|
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
|
<div class="card-body">
|
|
<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">
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Member</th>
|
|
<th>Client</th>
|
|
<th>Tags</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 flex-wrap gap-1">
|
|
{e.tags.map(tag => (
|
|
<div class="badge badge-sm badge-outline flex items-center gap-1">
|
|
{tag.color && (
|
|
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
|
)}
|
|
<span>{tag.name}</span>
|
|
</div>
|
|
))}
|
|
{e.tags.length === 0 && <span class="opacity-50">-</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>
|