All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
226 lines
8.1 KiB
Plaintext
226 lines
8.1 KiB
Plaintext
---
|
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
|
import { Icon } from 'astro-icon/components';
|
|
import { db } from '../../../../db';
|
|
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
|
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
|
import { formatTimeRange } from '../../../../lib/formatTime';
|
|
|
|
const user = Astro.locals.user;
|
|
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 client = await db.select()
|
|
.from(clients)
|
|
.where(and(
|
|
eq(clients.id, id),
|
|
eq(clients.organizationId, userMembership.organizationId)
|
|
))
|
|
.get();
|
|
|
|
if (!client) return Astro.redirect('/dashboard/clients');
|
|
|
|
// Get recent activity
|
|
const recentEntries = await db.select({
|
|
entry: timeEntries,
|
|
category: categories,
|
|
user: users,
|
|
})
|
|
.from(timeEntries)
|
|
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
|
|
.leftJoin(users, eq(timeEntries.userId, users.id))
|
|
.where(eq(timeEntries.clientId, client.id))
|
|
.orderBy(desc(timeEntries.startTime))
|
|
.limit(10)
|
|
.all();
|
|
|
|
// Calculate total time tracked
|
|
const totalTimeResult = await db.select({
|
|
totalDuration: sql<number>`sum(CASE WHEN ${timeEntries.endTime} IS NOT NULL THEN ${timeEntries.endTime} - ${timeEntries.startTime} ELSE 0 END)`
|
|
})
|
|
.from(timeEntries)
|
|
.where(eq(timeEntries.clientId, client.id))
|
|
.get();
|
|
|
|
const totalDurationMs = totalTimeResult?.totalDuration || 0;
|
|
const totalHours = Math.floor(totalDurationMs / (1000 * 60 * 60));
|
|
const totalMinutes = Math.floor((totalDurationMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
// Get total entries count
|
|
const totalEntriesResult = await db.select({ count: sql<number>`count(*)` })
|
|
.from(timeEntries)
|
|
.where(eq(timeEntries.clientId, client.id))
|
|
.get();
|
|
const totalEntriesCount = totalEntriesResult?.count || 0;
|
|
---
|
|
|
|
<DashboardLayout title={`${client.name} - Clients - Chronus`}>
|
|
<div class="flex items-center gap-3 mb-6">
|
|
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">
|
|
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
|
</a>
|
|
<h1 class="text-3xl font-bold">{client.name}</h1>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
|
<!-- Client Details Card -->
|
|
<div class="card bg-base-100 shadow-xl border border-base-200 lg:col-span-2">
|
|
<div class="card-body">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
|
<div class="space-y-2 mb-4">
|
|
{client.email && (
|
|
<div class="flex items-center gap-2 text-base-content/70">
|
|
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
|
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
|
</div>
|
|
)}
|
|
{client.phone && (
|
|
<div class="flex items-center gap-2 text-base-content/70">
|
|
<Icon name="heroicons:phone" class="w-4 h-4" />
|
|
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
|
|
</div>
|
|
)}
|
|
{(client.street || client.city || client.state || client.zip || client.country) && (
|
|
<div class="flex items-start gap-2 text-base-content/70">
|
|
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
|
|
<div class="text-sm space-y-0.5">
|
|
{client.street && <div>{client.street}</div>}
|
|
{(client.city || client.state || client.zip) && (
|
|
<div>
|
|
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
|
</div>
|
|
)}
|
|
{client.country && <div>{client.country}</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
|
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
|
Edit
|
|
</a>
|
|
<form method="POST" action={`/api/clients/${client.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this client? This will also delete all associated time entries.');">
|
|
<button type="submit" class="btn btn-error btn-outline btn-sm">
|
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
|
Delete
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Meta Info Card -->
|
|
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit">
|
|
<div class="card-body">
|
|
<h3 class="card-title text-lg mb-4">Information</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<div class="text-sm font-medium text-base-content/60">Created</div>
|
|
<div>{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
|
</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 mb-4">Recent Activity</h2>
|
|
|
|
{recentEntries.length > 0 ? (
|
|
<div class="overflow-x-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
<th>Category</th>
|
|
<th>User</th>
|
|
<th>Date</th>
|
|
<th>Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentEntries.map(({ entry, category, user: entryUser }) => (
|
|
<tr>
|
|
<td>{entry.description || '-'}</td>
|
|
<td>
|
|
{category ? (
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
|
|
<span>{category.name}</span>
|
|
</div>
|
|
) : '-'}
|
|
</td>
|
|
<td>{entryUser?.name || 'Unknown'}</td>
|
|
<td>{entry.startTime.toLocaleDateString()}</td>
|
|
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div class="text-center py-8 text-base-content/60">
|
|
No time entries recorded for this client yet.
|
|
</div>
|
|
)}
|
|
|
|
{recentEntries.length > 0 && (
|
|
<div class="card-actions justify-center mt-4">
|
|
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-sm">
|
|
View All Entries
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DashboardLayout>
|