This commit is contained in:
@@ -5,7 +5,7 @@ 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 { 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';
|
||||
|
||||
@@ -23,7 +23,7 @@ const userMemberships = await db.select()
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
|
||||
@@ -120,7 +120,7 @@ const statsByMember = teamMembers.map(member => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
member,
|
||||
totalTime,
|
||||
@@ -136,7 +136,7 @@ const statsByCategory = allCategories.map(category => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
category,
|
||||
totalTime,
|
||||
@@ -152,7 +152,7 @@ const statsByClient = allClients.map(client => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
client,
|
||||
totalTime,
|
||||
@@ -167,6 +167,81 @@ const totalTime = entries.reduce((sum, e) => {
|
||||
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';
|
||||
@@ -247,7 +322,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<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">
|
||||
@@ -270,6 +345,17 @@ function getTimeRangeLabel(range: string) {
|
||||
</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">
|
||||
@@ -282,6 +368,121 @@ function getTimeRangeLabel(range: string) {
|
||||
</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 && (
|
||||
<>
|
||||
@@ -295,8 +496,8 @@ function getTimeRangeLabel(range: string) {
|
||||
Category Distribution
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<CategoryChart
|
||||
client:load
|
||||
<CategoryChart
|
||||
client:load
|
||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.category.name,
|
||||
totalTime: s.totalTime,
|
||||
@@ -317,8 +518,8 @@ function getTimeRangeLabel(range: string) {
|
||||
Time by Client
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<ClientChart
|
||||
client:load
|
||||
<ClientChart
|
||||
client:load
|
||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.client.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -339,8 +540,8 @@ function getTimeRangeLabel(range: string) {
|
||||
Time by Team Member
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<MemberChart
|
||||
client:load
|
||||
<MemberChart
|
||||
client:load
|
||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.member.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -427,9 +628,9 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-primary w-20"
|
||||
value={stat.totalTime}
|
||||
<progress
|
||||
class="progress progress-primary w-20"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
@@ -472,9 +673,9 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-secondary w-20"
|
||||
value={stat.totalTime}
|
||||
<progress
|
||||
class="progress progress-secondary w-20"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
@@ -532,7 +733,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</td>
|
||||
<td>{e.entry.description || '-'}</td>
|
||||
<td class="font-mono">
|
||||
{e.entry.endTime
|
||||
{e.entry.endTime
|
||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||
: 'Running...'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user