229 lines
7.7 KiB
Plaintext
229 lines
7.7 KiB
Plaintext
---
|
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
|
import Icon from '../../components/Icon.astro';
|
|
import StatCard from '../../components/StatCard.astro';
|
|
import ColorDot from '../../components/ColorDot.vue';
|
|
import { db } from '../../db';
|
|
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
|
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
|
import { formatDuration } from '../../lib/formatTime';
|
|
|
|
const user = Astro.locals.user;
|
|
if (!user) return Astro.redirect('/login');
|
|
|
|
// Get current team from cookie or first membership
|
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
|
|
|
const userOrgs = await db.select({
|
|
id: organizations.id,
|
|
name: organizations.name,
|
|
role: members.role,
|
|
organizationId: members.organizationId,
|
|
})
|
|
.from(members)
|
|
.innerJoin(organizations, eq(members.organizationId, organizations.id))
|
|
.where(eq(members.userId, user.id))
|
|
.all();
|
|
|
|
// Use current team or fallback to first
|
|
const currentOrg = currentTeamId
|
|
? userOrgs.find(o => o.organizationId === currentTeamId) || userOrgs[0]
|
|
: userOrgs[0];
|
|
|
|
let stats = {
|
|
totalTimeThisWeek: 0,
|
|
totalTimeThisMonth: 0,
|
|
activeTimers: 0,
|
|
totalClients: 0,
|
|
recentEntries: [] as any[],
|
|
};
|
|
|
|
if (currentOrg) {
|
|
const now = new Date();
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const weekStats = await db.select({
|
|
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
|
})
|
|
.from(timeEntries)
|
|
.where(and(
|
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
|
gte(timeEntries.startTime, weekAgo),
|
|
sql`${timeEntries.endTime} IS NOT NULL`
|
|
))
|
|
.get();
|
|
|
|
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
|
|
|
|
const monthStats = await db.select({
|
|
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
|
})
|
|
.from(timeEntries)
|
|
.where(and(
|
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
|
gte(timeEntries.startTime, monthAgo),
|
|
sql`${timeEntries.endTime} IS NOT NULL`
|
|
))
|
|
.get();
|
|
|
|
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
|
|
|
|
const activeCount = await db.select({ count: sql<number>`count(*)` })
|
|
.from(timeEntries)
|
|
.where(and(
|
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
|
isNull(timeEntries.endTime)
|
|
))
|
|
.get();
|
|
|
|
stats.activeTimers = activeCount?.count || 0;
|
|
|
|
const clientCount = await db.select({ count: sql<number>`count(*)` })
|
|
.from(clients)
|
|
.where(eq(clients.organizationId, currentOrg.organizationId))
|
|
.get();
|
|
|
|
stats.totalClients = clientCount?.count || 0;
|
|
|
|
stats.recentEntries = await db.select({
|
|
entry: timeEntries,
|
|
client: clients,
|
|
tag: tags,
|
|
})
|
|
.from(timeEntries)
|
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
|
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
|
.orderBy(desc(timeEntries.startTime))
|
|
.limit(5)
|
|
.all();
|
|
}
|
|
|
|
const hasMembership = userOrgs.length > 0;
|
|
|
|
---
|
|
|
|
<DashboardLayout title="Dashboard - Chronus">
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-extrabold tracking-tight">
|
|
Dashboard
|
|
</h1>
|
|
<p class="text-base-content/60 text-sm mt-1">Welcome back, {user.name}!</p>
|
|
</div>
|
|
<a href="/dashboard/organizations/new" class="btn btn-ghost btn-sm">
|
|
<Icon name="plus" class="w-4 h-4" />
|
|
New Team
|
|
</a>
|
|
</div>
|
|
|
|
{!hasMembership && (
|
|
<div class="alert alert-info mb-6 text-sm">
|
|
<Icon name="information-circle" class="w-5 h-5" />
|
|
<div>
|
|
<h3 class="font-bold">Welcome to Chronus!</h3>
|
|
<div class="text-xs">You're not part of any team yet. Create one or wait for an invitation.</div>
|
|
</div>
|
|
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
|
<Icon name="plus" class="w-4 h-4" />
|
|
New Team
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{hasMembership && (
|
|
<>
|
|
<!-- Stats Overview -->
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
|
<StatCard
|
|
title="This Week"
|
|
value={formatDuration(stats.totalTimeThisWeek)}
|
|
description="Total tracked time"
|
|
icon="clock"
|
|
color="text-primary"
|
|
/>
|
|
<StatCard
|
|
title="This Month"
|
|
value={formatDuration(stats.totalTimeThisMonth)}
|
|
description="Total tracked time"
|
|
icon="calendar"
|
|
color="text-secondary"
|
|
/>
|
|
<StatCard
|
|
title="Active Timers"
|
|
value={String(stats.activeTimers)}
|
|
description="Currently running"
|
|
icon="play-circle"
|
|
color="text-accent"
|
|
/>
|
|
<StatCard
|
|
title="Clients"
|
|
value={String(stats.totalClients)}
|
|
description="Total active"
|
|
icon="building-office"
|
|
color="text-info"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<!-- Quick Actions -->
|
|
<div class="card card-border bg-base-100">
|
|
<div class="card-body p-4">
|
|
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
<Icon name="bolt" class="w-4 h-4 text-warning" />
|
|
Quick Actions
|
|
</h2>
|
|
<div class="flex flex-col gap-2 mt-3">
|
|
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
|
|
<Icon name="play" class="w-4 h-4" />
|
|
Start Timer
|
|
</a>
|
|
<a href="/dashboard/clients/new" class="btn btn-ghost btn-sm">
|
|
<Icon name="plus" class="w-4 h-4" />
|
|
Add Client
|
|
</a>
|
|
<a href="/dashboard/reports" class="btn btn-ghost btn-sm">
|
|
<Icon name="chart-bar" class="w-4 h-4" />
|
|
View Reports
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Activity -->
|
|
<div class="card card-border bg-base-100">
|
|
<div class="card-body p-4">
|
|
<h2 class="text-sm font-semibold flex items-center gap-2">
|
|
<Icon name="clock" class="w-4 h-4 text-success" />
|
|
Recent Activity
|
|
</h2>
|
|
{stats.recentEntries.length > 0 ? (
|
|
<ul class="space-y-2 mt-3">
|
|
{stats.recentEntries.map(({ entry, client, tag }) => (
|
|
<ColorDot client:load as="li" color={tag?.color || 'oklch(var(--p))'} borderColor class="p-2.5 rounded-lg bg-base-200 border-l-3 hover:bg-base-300 transition-colors">
|
|
<div class="font-medium text-sm">{client.name}</div>
|
|
<div class="text-xs text-base-content/60 mt-0.5 flex flex-wrap gap-2 items-center">
|
|
<span class="flex gap-1 flex-wrap">
|
|
{tag ? (
|
|
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
|
) : <span class="italic opacity-50">No tag</span>}
|
|
</span>
|
|
<span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
|
</div>
|
|
</ColorDot>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div class="flex flex-col items-center justify-center py-6 text-center mt-3">
|
|
<Icon name="clock" class="w-10 h-10 text-base-content/30 mb-2" />
|
|
<p class="text-base-content/60 text-sm">No recent time entries</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DashboardLayout>
|