Refactored a bunch of shit
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s
This commit is contained in:
@@ -1,26 +1,15 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { db } from '../../db';
|
||||
import { clients, members } from '../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { clients } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const organizationId = userMembership.organizationId;
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, members } from '../../../../db/schema';
|
||||
import { clients } from '../../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
|
||||
const { id } = Astro.params;
|
||||
if (!id) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// 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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const client = await db.select()
|
||||
.from(clients)
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
|
||||
import { clients, timeEntries, tags, users } from '../../../../db/schema';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||
import StatCard from '../../../../components/StatCard.astro';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
|
||||
const { id } = Astro.params;
|
||||
if (!id) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// 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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const client = await db.select()
|
||||
.from(clients)
|
||||
@@ -132,23 +122,20 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="stats shadow w-full">
|
||||
<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 Tracked</div>
|
||||
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
|
||||
<div class="stat-desc">Across all projects</div>
|
||||
</div>
|
||||
|
||||
<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">{totalEntriesCount}</div>
|
||||
<div class="stat-desc">Recorded entries</div>
|
||||
</div>
|
||||
<StatCard
|
||||
title="Total Time Tracked"
|
||||
value={`${totalHours}h ${totalMinutes}m`}
|
||||
description="Across all projects"
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Entries"
|
||||
value={String(totalEntriesCount)}
|
||||
description="Recorded entries"
|
||||
icon="heroicons:list-bullet"
|
||||
color="text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StatCard from '../../components/StatCard.astro';
|
||||
import { db } from '../../db';
|
||||
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||
@@ -134,41 +135,38 @@ const hasMembership = userOrgs.length > 0;
|
||||
<>
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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>
|
||||
<StatCard
|
||||
title="This Week"
|
||||
value={formatDuration(stats.totalTimeThisWeek)}
|
||||
description="Total tracked time"
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
valueClass="text-3xl"
|
||||
/>
|
||||
<StatCard
|
||||
title="This Month"
|
||||
value={formatDuration(stats.totalTimeThisMonth)}
|
||||
description="Total tracked time"
|
||||
icon="heroicons:calendar"
|
||||
color="text-secondary"
|
||||
valueClass="text-3xl"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Timers"
|
||||
value={String(stats.activeTimers)}
|
||||
description="Currently running"
|
||||
icon="heroicons:play-circle"
|
||||
color="text-accent"
|
||||
valueClass="text-3xl"
|
||||
/>
|
||||
<StatCard
|
||||
title="Clients"
|
||||
value={String(stats.totalClients)}
|
||||
description="Total active"
|
||||
icon="heroicons:building-office"
|
||||
color="text-info"
|
||||
valueClass="text-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { formatCurrency } from '../../../lib/formatTime';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
@@ -49,13 +50,6 @@ const items = await db.select()
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||
.all();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: invoice.currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const isDraft = invoice.status === 'draft';
|
||||
---
|
||||
|
||||
@@ -235,8 +229,8 @@ const isDraft = invoice.status === 'draft';
|
||||
<tr>
|
||||
<td class="py-4">{item.description}</td>
|
||||
<td class="py-4 text-right">{item.quantity}</td>
|
||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
|
||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
|
||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice, invoice.currency)}</td>
|
||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount, invoice.currency)}</td>
|
||||
{isDraft && (
|
||||
<td class="py-4 text-right">
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
|
||||
@@ -299,7 +293,7 @@ const isDraft = invoice.status === 'draft';
|
||||
<div class="w-64 space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||
</div>
|
||||
{(invoice.discountAmount && invoice.discountAmount > 0) && (
|
||||
<div class="flex justify-between text-sm">
|
||||
@@ -307,7 +301,7 @@ const isDraft = invoice.status === 'draft';
|
||||
Discount
|
||||
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
|
||||
</span>
|
||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
|
||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
||||
@@ -320,13 +314,13 @@ const isDraft = invoice.status === 'draft';
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span class="text-primary">{formatCurrency(invoice.total)}</span>
|
||||
<span class="text-primary">{formatCurrency(invoice.total, invoice.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StatCard from '../../../components/StatCard.astro';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, clients, members } from '../../../db/schema';
|
||||
import { invoices, clients } from '../../../db/schema';
|
||||
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
import { formatCurrency } 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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const currentTeamIdResolved = userMembership.organizationId;
|
||||
|
||||
@@ -96,13 +87,6 @@ const yearInvoices = allInvoicesRaw.filter(i => {
|
||||
return issueDate >= yearStart && issueDate <= yearEnd;
|
||||
});
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'badge-success';
|
||||
@@ -130,40 +114,35 @@ const getStatusColor = (status: string) => {
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:document-text" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Invoices</div>
|
||||
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
|
||||
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div>
|
||||
</div>
|
||||
<StatCard
|
||||
title="Total Invoices"
|
||||
value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
|
||||
description={selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}
|
||||
icon="heroicons:document-text"
|
||||
color="text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Open Quotes</div>
|
||||
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
|
||||
<div class="stat-desc">Waiting for approval</div>
|
||||
</div>
|
||||
<StatCard
|
||||
title="Open Quotes"
|
||||
value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
|
||||
description="Waiting for approval"
|
||||
icon="heroicons:clipboard-document-list"
|
||||
color="text-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Revenue</div>
|
||||
<div class="stat-value text-success">
|
||||
{formatCurrency(yearInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
||||
</div>
|
||||
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
|
||||
</div>
|
||||
<StatCard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(yearInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
||||
description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
|
||||
icon="heroicons:currency-dollar"
|
||||
color="text-success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,26 +2,15 @@
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { clients, members, invoices, organizations } from '../../../db/schema';
|
||||
import { clients, invoices, organizations } from '../../../db/schema';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
|
||||
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const currentTeamIdResolved = userMembership.organizationId;
|
||||
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StatCard from '../../components/StatCard.astro';
|
||||
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, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||
import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const teamMembers = await db.select({
|
||||
id: users.id,
|
||||
@@ -247,13 +237,6 @@ const revenueByClient = allClients.map(client => {
|
||||
};
|
||||
}).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';
|
||||
@@ -383,46 +366,44 @@ function getTimeRangeLabel(range: string) {
|
||||
<!-- 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>
|
||||
<StatCard
|
||||
title="Total Time"
|
||||
value={formatDuration(totalTime)}
|
||||
description={getTimeRangeLabel(timeRange)}
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
</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>
|
||||
<StatCard
|
||||
title="Total Entries"
|
||||
value={String(entries.length)}
|
||||
description={getTimeRangeLabel(timeRange)}
|
||||
icon="heroicons:list-bullet"
|
||||
color="text-secondary"
|
||||
/>
|
||||
</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>
|
||||
<StatCard
|
||||
title="Revenue"
|
||||
value={formatCurrency(revenueStats.total)}
|
||||
description={`${invoiceStats.paid} paid invoices`}
|
||||
icon="heroicons:currency-dollar"
|
||||
color="text-success"
|
||||
/>
|
||||
</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>
|
||||
<StatCard
|
||||
title="Active Members"
|
||||
value={String(statsByMember.filter(s => s.entryCount > 0).length)}
|
||||
description={`of ${teamMembers.length} total`}
|
||||
icon="heroicons:user-group"
|
||||
color="text-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,10 +50,10 @@ const userPasskeys = await db.select()
|
||||
)}
|
||||
|
||||
<!-- Profile Information -->
|
||||
<ProfileForm client:load user={user} />
|
||||
<ProfileForm client:idle user={user} />
|
||||
|
||||
<!-- Change Password -->
|
||||
<PasswordForm client:load />
|
||||
<PasswordForm client:idle />
|
||||
|
||||
<!-- Passkeys -->
|
||||
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
|
||||
|
||||
@@ -5,24 +5,13 @@ import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { members, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const teamMembers = await db.select({
|
||||
member: members,
|
||||
|
||||
@@ -2,26 +2,15 @@
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { members, organizations, tags } from '../../../db/schema';
|
||||
import { organizations, tags } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
|
||||
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
|
||||
@@ -4,27 +4,16 @@ import { Icon } from 'astro-icon/components';
|
||||
import Timer from '../../components/Timer.vue';
|
||||
import ManualEntry from '../../components/ManualEntry.vue';
|
||||
import { db } from '../../db';
|
||||
import { timeEntries, clients, members, tags, users } from '../../db/schema';
|
||||
import { timeEntries, clients, tags, users } from '../../db/schema';
|
||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const organizationId = userMembership.organizationId;
|
||||
|
||||
@@ -153,12 +142,12 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
||||
|
||||
<!-- Tabs for Timer and Manual Entry -->
|
||||
<div role="tablist" class="tabs tabs-lifted mb-6">
|
||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
<div class="tabs tabs-lift mb-6">
|
||||
<input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
@@ -177,11 +166,11 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
<input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user