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,12 +7,20 @@ import { eq } from 'drizzle-orm';
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 allCategories = await db.select()
.from(categories)
@@ -20,7 +28,7 @@ const allCategories = await db.select()
.all();
---
<DashboardLayout title="Categories - Zamaan">
<DashboardLayout title="Categories - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Categories</h1>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>

View File

@@ -7,22 +7,30 @@ import { eq, and } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const userOrgs = 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))
.all();
const orgIds = userOrgs.map(m => m.organizationId);
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
const allClients = orgIds.length > 0
? await db.select()
.from(clients)
.where(eq(clients.organizationId, orgIds[0]))
.all()
: [];
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const organizationId = userMembership.organizationId;
const allClients = await db.select()
.from(clients)
.where(eq(clients.organizationId, organizationId))
.all();
---
<DashboardLayout title="Clients - Zamaan">
<DashboardLayout title="Clients - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Clients</h1>
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>

View File

@@ -5,7 +5,7 @@ const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="New Client - Zamaan">
<DashboardLayout title="New Client - Chronus">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>

View File

@@ -4,10 +4,14 @@ import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { organizations, members, timeEntries, clients, categories } 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,
@@ -19,7 +23,11 @@ const userOrgs = await db.select({
.where(eq(members.userId, user.id))
.all();
const firstOrg = userOrgs[0];
// 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,
@@ -28,7 +36,7 @@ let stats = {
recentEntries: [] as any[],
};
if (firstOrg) {
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);
@@ -36,7 +44,7 @@ if (firstOrg) {
const weekEntries = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, firstOrg.organizationId),
eq(timeEntries.organizationId, currentOrg.organizationId),
gte(timeEntries.startTime, weekAgo)
))
.all();
@@ -51,7 +59,7 @@ if (firstOrg) {
const monthEntries = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, firstOrg.organizationId),
eq(timeEntries.organizationId, currentOrg.organizationId),
gte(timeEntries.startTime, monthAgo)
))
.all();
@@ -66,7 +74,7 @@ if (firstOrg) {
const activeCount = await db.select()
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, firstOrg.organizationId),
eq(timeEntries.organizationId, currentOrg.organizationId),
isNull(timeEntries.endTime)
))
.all();
@@ -75,7 +83,7 @@ if (firstOrg) {
const clientCount = await db.select()
.from(clients)
.where(eq(clients.organizationId, firstOrg.organizationId))
.where(eq(clients.organizationId, currentOrg.organizationId))
.all();
stats.totalClients = clientCount.length;
@@ -88,144 +96,137 @@ if (firstOrg) {
.from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.where(eq(timeEntries.userId, user.id))
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
.orderBy(desc(timeEntries.startTime))
.limit(5)
.all();
}
function formatDuration(ms: number) {
const totalMinutes = Math.round(ms / 1000 / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${minutes}m`;
}
const hasMembership = userOrgs.length > 0;
---
<DashboardLayout title="Dashboard - Zamaan">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Dashboard</h1>
<a href="/dashboard/organizations/new" class="btn btn-outline btn-sm">
<DashboardLayout title="Dashboard - Chronus">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-4xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent mb-2">
Dashboard
</h1>
<p class="text-base-content/60">Welcome back, {user.name}!</p>
</div>
<a href="/dashboard/organizations/new" class="btn btn-outline">
<Icon name="heroicons:plus" class="w-5 h-5" />
New Organization
New Team
</a>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 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">This Week</div>
<div class="stat-value text-primary text-2xl">{formatDuration(stats.totalTimeThisWeek)}</div>
<div class="stat-desc">Total tracked time</div>
{!hasMembership && (
<div class="alert alert-info mb-8">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<div>
<h3 class="font-bold">Welcome to Chronus!</h3>
<div class="text-sm">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="heroicons:plus" class="w-4 h-4" />
New Team
</a>
</div>
)}
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:calendar" class="w-8 h-8" />
{hasMembership && (
<>
<!-- Stats Overview -->
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">This Week</div>
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
<div class="stat-title">This Month</div>
<div class="stat-value text-secondary text-2xl">{formatDuration(stats.totalTimeThisMonth)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:play-circle" class="w-8 h-8" />
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:calendar" class="w-8 h-8" />
</div>
<div class="stat-title">This Month</div>
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
<div class="stat-title">Active Timers</div>
<div class="stat-value text-accent text-2xl">{stats.activeTimers}</div>
<div class="stat-desc">Currently running</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-info">
<Icon name="heroicons:building-office" class="w-8 h-8" />
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:play-circle" class="w-8 h-8" />
</div>
<div class="stat-title">Active Timers</div>
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
<div class="stat-desc">Currently running</div>
</div>
<div class="stat">
<div class="stat-figure text-info">
<Icon name="heroicons:building-office" class="w-8 h-8" />
</div>
<div class="stat-title">Clients</div>
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
<div class="stat-desc">Total active</div>
</div>
<div class="stat-title">Clients</div>
<div class="stat-value text-info text-2xl">{stats.totalClients}</div>
<div class="stat-desc">Total active</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Organizations -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:building-office-2" class="w-6 h-6" />
Your Organizations
</h2>
<ul class="menu bg-base-100 w-full p-0">
{userOrgs.map(org => (
<li>
<a class="flex justify-between">
<span>{org.name}</span>
<span class="badge badge-sm">{org.role}</span>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Quick Actions -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:bolt" class="w-6 h-6 text-warning" />
Quick Actions
</h2>
<div class="flex flex-col gap-3 mt-4">
<a href="/dashboard/tracker" class="btn btn-primary">
<Icon name="heroicons:play" class="w-5 h-5" />
Start Timer
</a>
</li>
))}
</ul>
</div>
</div>
<a href="/dashboard/clients/new" class="btn btn-outline">
<Icon name="heroicons:plus" class="w-5 h-5" />
Add Client
</a>
<a href="/dashboard/reports" class="btn btn-outline">
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
View Reports
</a>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:bolt" class="w-6 h-6" />
Quick Actions
</h2>
<div class="flex flex-col gap-2">
<a href="/dashboard/tracker" class="btn btn-primary">
<Icon name="heroicons:play" class="w-5 h-5" />
Start Timer
</a>
<a href="/dashboard/clients/new" class="btn btn-outline">
<Icon name="heroicons:plus" class="w-5 h-5" />
Add Client
</a>
<a href="/dashboard/reports" class="btn btn-outline">
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
View Reports
</a>
<!-- Recent Activity -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:clock" class="w-6 h-6 text-success" />
Recent Activity
</h2>
{stats.recentEntries.length > 0 ? (
<ul class="space-y-3 mt-4">
{stats.recentEntries.map(({ entry, client, category }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
<div class="font-semibold text-sm">{client.name}</div>
<div class="text-xs text-base-content/60 mt-1">
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
</div>
</li>
))}
</ul>
) : (
<div class="flex flex-col items-center justify-center py-8 text-center mt-4">
<Icon name="heroicons:clock" class="w-12 h-12 text-base-content/20 mb-3" />
<p class="text-base-content/60 text-sm">No recent time entries</p>
</div>
)}
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:clock" class="w-6 h-6" />
Recent Activity
</h2>
{stats.recentEntries.length > 0 ? (
<ul class="space-y-2">
{stats.recentEntries.map(({ entry, client, category }) => (
<li class="text-sm border-l-2 pl-2" style={`border-color: ${category.color || '#3b82f6'}`}>
<div class="font-semibold">{client.name}</div>
<div class="text-xs text-base-content/60">
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
</div>
</li>
))}
</ul>
) : (
<p class="text-base-content/60 text-sm">No recent time entries</p>
)}
</div>
</div>
</div>
</>
)}
</DashboardLayout>

View File

@@ -1,33 +1,33 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { organizations, members } from '../../db/schema';
import { db } from '../../../db';
import { organizations, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="Create Organization - Zamaan">
<DashboardLayout title="Create Team - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Create New Organization</h1>
<h1 class="text-3xl font-bold">Create New Team</h1>
</div>
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<div class="alert alert-info mb-4">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<span>Create a new organization to manage separate teams and projects. You'll be the owner.</span>
<span>Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
</div>
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Organization Name</span>
<span class="label-text font-medium">Team Name</span>
</label>
<input
type="text"
@@ -41,7 +41,7 @@ if (!user) return Astro.redirect('/login');
<div class="card-actions justify-end mt-6">
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Organization</button>
<button type="submit" class="btn btn-primary">Create Team</button>
</div>
</div>
</form>

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>

View File

@@ -0,0 +1,173 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const url = new URL(Astro.request.url);
const successType = url.searchParams.get('success');
---
<DashboardLayout title="Account Settings - Chronus">
<div class="max-w-4xl mx-auto px-4 sm:px-6">
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Account Settings
</h1>
{/* Success Messages */}
{successType === 'profile' && (
<div class="alert alert-success mb-6">
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span class="text-sm sm:text-base">Profile updated successfully!</span>
</div>
)}
{successType === 'password' && (
<div class="alert alert-success mb-6">
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span class="text-sm sm:text-base">Password changed successfully!</span>
</div>
)}
<!-- Profile Information -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
Profile Information
</h2>
<form action="/api/user/update-profile" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
</label>
<input
type="text"
name="name"
value={user.name}
placeholder="Your full name"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Email</span>
</label>
<input
type="email"
name="email"
value={user.email}
placeholder="your@email.com"
class="input input-bordered w-full"
disabled
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Change Password -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
Change Password
</h2>
<form action="/api/user/change-password" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
</label>
<input
type="password"
name="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
</label>
<input
type="password"
name="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
minlength="8"
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
</div>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
</label>
<input
type="password"
name="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
minlength="8"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
Update Password
</button>
</div>
</form>
</div>
</div>
<!-- Account Info -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:information-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
Account Information
</h2>
<div class="space-y-3">
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
<span class="text-base-content/70 text-sm sm:text-base">Account ID</span>
<span class="font-mono text-xs sm:text-sm break-all">{user.id}</span>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
<span class="text-base-content/70 text-sm sm:text-base">Email</span>
<span class="text-sm sm:text-base break-all">{user.email}</span>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between py-3 gap-2 sm:gap-0">
<span class="text-base-content/70 text-sm sm:text-base">Site Administrator</span>
<span class={user.isSiteAdmin ? "badge badge-primary" : "badge badge-ghost"}>
{user.isSiteAdmin ? "Yes" : "No"}
</span>
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -8,12 +8,20 @@ import { eq } from 'drizzle-orm';
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({
member: members,
@@ -28,7 +36,7 @@ const currentUserMember = teamMembers.find(m => m.user.id === user.id);
const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?.member.role === 'admin';
---
<DashboardLayout title="Team - Zamaan">
<DashboardLayout title="Team - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Team Members</h1>
<div class="flex gap-2">

View File

@@ -19,7 +19,7 @@ const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admi
if (!isAdmin) return Astro.redirect('/dashboard/team');
---
<DashboardLayout title="Invite Team Member - Zamaan">
<DashboardLayout title="Invite Team Member - Chronus">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Invite Team Member</h1>

View File

@@ -2,12 +2,15 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { categories, members } from '../../../db/schema';
import { categories, members, organizations } from '../../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMembership = await db.select()
.from(members)
.where(eq(members.userId, user.id))
@@ -18,13 +21,26 @@ if (!userMembership) return Astro.redirect('/dashboard');
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team');
// Use current team or fallback to membership org
const orgId = currentTeamId || userMembership.organizationId;
const organization = await db.select()
.from(organizations)
.where(eq(organizations.id, orgId))
.get();
if (!organization) return Astro.redirect('/dashboard');
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.where(eq(categories.organizationId, orgId))
.all();
const url = new URL(Astro.request.url);
const successType = url.searchParams.get('success');
---
<DashboardLayout title="Team Settings - Zamaan">
<DashboardLayout title="Team Settings - Chronus">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
@@ -32,6 +48,51 @@ const allCategories = await db.select()
<h1 class="text-3xl font-bold">Team Settings</h1>
</div>
<!-- Team Settings -->
<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-2" class="w-6 h-6" />
Team Settings
</h2>
{successType === 'org-name' && (
<div class="alert alert-success mb-4">
<Icon name="heroicons:check-circle" class="w-6 h-6" />
<span>Team name updated successfully!</span>
</div>
)}
<form action="/api/organizations/update-name" method="POST" class="space-y-4">
<input type="hidden" name="organizationId" value={organization.id} />
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Team Name</span>
</div>
<input
type="text"
name="name"
value={organization.name}
placeholder="Organization name"
class="input input-bordered w-full"
required
/>
<div class="label">
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span>
</div>
</label>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
<Icon name="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Categories Section -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
@@ -88,16 +149,4 @@ const allCategories = await db.select()
</div>
</div>
<!-- Future Settings Sections -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:cog-6-tooth" class="w-6 h-6" />
Organization Settings
</h2>
<p class="text-base-content/70">
Additional organization settings coming soon...
</p>
</div>
</div>
</DashboardLayout>

View File

@@ -31,7 +31,7 @@ const category = await db.select()
if (!category) return Astro.redirect('/dashboard/team/settings');
---
<DashboardLayout title="Edit Category - Zamaan">
<DashboardLayout title="Edit Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">

View File

@@ -6,7 +6,7 @@ const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="New Category - Zamaan">
<DashboardLayout title="New Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">

View File

@@ -5,30 +5,41 @@ import Timer from '../../components/Timer.vue';
import { db } from '../../db';
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const userOrg = 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 (!userOrg) 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 organizationId = userMembership.organizationId;
const allClients = await db.select()
.from(clients)
.where(eq(clients.organizationId, userOrg.organizationId))
.where(eq(clients.organizationId, organizationId))
.all();
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userOrg.organizationId))
.where(eq(categories.organizationId, organizationId))
.all();
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, userOrg.organizationId))
.where(eq(tags.organizationId, organizationId))
.all();
// Query params
@@ -43,7 +54,7 @@ const filterStatus = url.searchParams.get('status') || '';
const sortBy = url.searchParams.get('sort') || 'start-desc';
const searchTerm = url.searchParams.get('search') || '';
const conditions = [eq(timeEntries.organizationId, userOrg.organizationId)];
const conditions = [eq(timeEntries.organizationId, organizationId)];
if (filterClient) {
conditions.push(eq(timeEntries.clientId, filterClient));
@@ -113,15 +124,6 @@ const runningEntry = await db.select({
))
.get();
function formatDuration(start: Date, end: Date | null) {
if (!end) return 'Running...';
const ms = end.getTime() - start.getTime();
const minutes = Math.round(ms / (1000 * 60));
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
}
function getPaginationPages(currentPage: number, totalPages: number): number[] {
const pages: number[] = [];
const numPagesToShow = Math.min(5, totalPages);
@@ -146,7 +148,7 @@ function getPaginationPages(currentPage: number, totalPages: number): number[] {
const paginationPages = getPaginationPages(page, totalPages);
---
<DashboardLayout title="Time Tracker - Zamaan">
<DashboardLayout title="Time Tracker - Chronus">
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
{allClients.length === 0 ? (
@@ -313,7 +315,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<span class="badge badge-success">Running</span>
)}
</td>
<td class="font-mono">{formatDuration(entry.startTime, entry.endTime)}</td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button