First pass
This commit is contained in:
56
src/pages/dashboard/categories.astro
Normal file
56
src/pages/dashboard/categories.astro
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { db } from '../../db';
|
||||
import { categories, members } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's first organization
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
// Get all categories for the organization
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
---
|
||||
|
||||
<DashboardLayout title="Categories - Zamaan">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{allCategories.map(category => (
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{category.color && (
|
||||
<span class="w-4 h-4 rounded-full" style={`background-color: ${category.color}`}></span>
|
||||
)}
|
||||
{category.name}
|
||||
</h2>
|
||||
<p class="text-xs text-base-content/60">Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<a href={`/dashboard/categories/${category.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allCategories.length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<p class="text-base-content/60 mb-4">No categories yet</p>
|
||||
<a href="/dashboard/categories/new" class="btn btn-primary">Add Your First Category</a>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
55
src/pages/dashboard/clients.astro
Normal file
55
src/pages/dashboard/clients.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { db } from '../../db';
|
||||
import { clients, members } from '../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's organizations
|
||||
const userOrgs = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
const orgIds = userOrgs.map(m => m.organizationId);
|
||||
|
||||
// Get all clients for user's organizations
|
||||
const allClients = orgIds.length > 0
|
||||
? await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, orgIds[0]))
|
||||
.all()
|
||||
: [];
|
||||
---
|
||||
|
||||
<DashboardLayout title="Clients - Zamaan">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{allClients.map(client => (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{client.name}</h2>
|
||||
{client.email && <p class="text-sm text-gray-500">{client.email}</p>}
|
||||
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-sm btn-ghost">View</a>
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allClients.length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<p class="text-gray-500 mb-4">No clients yet</p>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Your First Client</a>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
48
src/pages/dashboard/clients/new.astro
Normal file
48
src/pages/dashboard/clients/new.astro
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
---
|
||||
|
||||
<DashboardLayout title="New Client - Zamaan">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
||||
|
||||
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text">Client Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text">Email (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="contact@acme.com"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Client</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
238
src/pages/dashboard/index.astro
Normal file
238
src/pages/dashboard/index.astro
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
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';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
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();
|
||||
|
||||
// Get stats for first organization
|
||||
const firstOrg = userOrgs[0];
|
||||
let stats = {
|
||||
totalTimeThisWeek: 0,
|
||||
totalTimeThisMonth: 0,
|
||||
activeTimers: 0,
|
||||
totalClients: 0,
|
||||
recentEntries: [] as any[],
|
||||
};
|
||||
|
||||
if (firstOrg) {
|
||||
// Calculate date ranges
|
||||
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);
|
||||
|
||||
// Get time entries for this week
|
||||
const weekEntries = await db.select()
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, firstOrg.organizationId),
|
||||
gte(timeEntries.startTime, weekAgo)
|
||||
))
|
||||
.all();
|
||||
|
||||
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
|
||||
if (e.endTime) {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Get time entries for this month
|
||||
const monthEntries = await db.select()
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, firstOrg.organizationId),
|
||||
gte(timeEntries.startTime, monthAgo)
|
||||
))
|
||||
.all();
|
||||
|
||||
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
|
||||
if (e.endTime) {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Count active timers
|
||||
const activeCount = await db.select()
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, firstOrg.organizationId),
|
||||
isNull(timeEntries.endTime)
|
||||
))
|
||||
.all();
|
||||
|
||||
stats.activeTimers = activeCount.length;
|
||||
|
||||
// Count clients
|
||||
const clientCount = await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, firstOrg.organizationId))
|
||||
.all();
|
||||
|
||||
stats.totalClients = clientCount.length;
|
||||
|
||||
// Get recent entries
|
||||
stats.recentEntries = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
category: categories,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.where(eq(timeEntries.userId, user.id))
|
||||
.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`;
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<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">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
New Organization
|
||||
</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>
|
||||
</div>
|
||||
</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" />
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
</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>
|
||||
49
src/pages/dashboard/organizations/new.astro
Normal file
49
src/pages/dashboard/organizations/new.astro
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="name">
|
||||
<span class="label-text font-medium">Organization Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
539
src/pages/dashboard/reports.astro
Normal file
539
src/pages/dashboard/reports.astro
Normal file
@@ -0,0 +1,539 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
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 { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's organization
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
// Get all team members
|
||||
const teamMembers = await db.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
})
|
||||
.from(members)
|
||||
.innerJoin(users, eq(members.userId, users.id))
|
||||
.where(eq(members.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
// Get all categories
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
// Get all clients
|
||||
const allClients = await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
// Parse filter parameters
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedMemberId = url.searchParams.get('member') || '';
|
||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
||||
const selectedClientId = url.searchParams.get('client') || '';
|
||||
const timeRange = url.searchParams.get('range') || 'week';
|
||||
|
||||
// Calculate date range
|
||||
const now = new Date();
|
||||
let startDate = new Date();
|
||||
let endDate = new Date();
|
||||
|
||||
switch (timeRange) {
|
||||
case 'today':
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case 'week':
|
||||
startDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
startDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'mtd':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
break;
|
||||
case 'ytd':
|
||||
startDate = new Date(now.getFullYear(), 0, 1);
|
||||
break;
|
||||
case 'last-month':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
|
||||
// Build query conditions
|
||||
const conditions = [
|
||||
eq(timeEntries.organizationId, userMembership.organizationId),
|
||||
gte(timeEntries.startTime, startDate),
|
||||
lte(timeEntries.startTime, endDate),
|
||||
];
|
||||
|
||||
if (selectedMemberId) {
|
||||
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
||||
}
|
||||
|
||||
if (selectedCategoryId) {
|
||||
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
|
||||
}
|
||||
|
||||
if (selectedClientId) {
|
||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||
}
|
||||
|
||||
// Fetch detailed entries
|
||||
const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
user: users,
|
||||
client: clients,
|
||||
category: categories,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.all();
|
||||
|
||||
// Calculate statistics by member
|
||||
const statsByMember = teamMembers.map(member => {
|
||||
const memberEntries = entries.filter(e => e.user.id === member.id);
|
||||
const totalTime = memberEntries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
member,
|
||||
totalTime,
|
||||
entryCount: memberEntries.length,
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
// Calculate statistics by category
|
||||
const statsByCategory = allCategories.map(category => {
|
||||
const categoryEntries = entries.filter(e => e.category.id === category.id);
|
||||
const totalTime = categoryEntries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
category,
|
||||
totalTime,
|
||||
entryCount: categoryEntries.length,
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
// Calculate statistics by client
|
||||
const statsByClient = allClients.map(client => {
|
||||
const clientEntries = entries.filter(e => e.client.id === client.id);
|
||||
const totalTime = clientEntries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
client,
|
||||
totalTime,
|
||||
entryCount: clientEntries.length,
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
// Calculate total time
|
||||
const totalTime = entries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
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';
|
||||
case 'week': return 'Last 7 Days';
|
||||
case 'month': return 'Last 30 Days';
|
||||
case 'mtd': return 'Month to Date';
|
||||
case 'ytd': return 'Year to Date';
|
||||
case 'last-month': return 'Last Month';
|
||||
default: return 'Last 7 Days';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<DashboardLayout title="Reports - Zamaan">
|
||||
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Time Range</span>
|
||||
</label>
|
||||
<select name="range" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="today" selected={timeRange === 'today'}>Today</option>
|
||||
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
|
||||
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
|
||||
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
|
||||
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
|
||||
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Team Member</span>
|
||||
</label>
|
||||
<select name="member" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Members</option>
|
||||
{teamMembers.map(member => (
|
||||
<option value={member.id} selected={selectedMemberId === member.id}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={selectedCategoryId === category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Clients</option>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={selectedClientId === client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<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 -->
|
||||
<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">
|
||||
<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 -->
|
||||
<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 => (
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 -->
|
||||
<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 => (
|
||||
<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>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
126
src/pages/dashboard/team.astro
Normal file
126
src/pages/dashboard/team.astro
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { members, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's first organization
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
// Get all team members for this organization
|
||||
const teamMembers = await db.select({
|
||||
member: members,
|
||||
user: users,
|
||||
})
|
||||
.from(members)
|
||||
.innerJoin(users, eq(members.userId, users.id))
|
||||
.where(eq(members.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
const currentUserMember = teamMembers.find(m => m.user.id === user.id);
|
||||
const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?.member.role === 'admin';
|
||||
---
|
||||
|
||||
<DashboardLayout title="Team - Zamaan">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Team Members</h1>
|
||||
<div class="flex gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||
Settings
|
||||
</a>
|
||||
<a href="/dashboard/team/invite" class="btn btn-primary">Invite Member</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
{isAdmin && <th>Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teamMembers.map(({ member, user: teamUser }) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
||||
<span>{teamUser.name.charAt(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold">{teamUser.name}</div>
|
||||
{teamUser.id === user.id && (
|
||||
<span class="badge badge-sm">You</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{teamUser.email}</td>
|
||||
<td>
|
||||
<span class={`badge ${
|
||||
member.role === 'owner' ? 'badge-primary' :
|
||||
member.role === 'admin' ? 'badge-secondary' :
|
||||
'badge-ghost'
|
||||
}`}>
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
{isAdmin && (
|
||||
<td>
|
||||
{teamUser.id !== user.id && member.role !== 'owner' && (
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<li>
|
||||
<form method="POST" action={`/api/team/change-role`}>
|
||||
<input type="hidden" name="userId" value={teamUser.id} />
|
||||
<input type="hidden" name="role" value={member.role === 'admin' ? 'member' : 'admin'} />
|
||||
<button type="submit">
|
||||
{member.role === 'admin' ? 'Make Member' : 'Make Admin'}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action={`/api/team/remove`}>
|
||||
<input type="hidden" name="userId" value={teamUser.id} />
|
||||
<button type="submit" class="text-error">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
68
src/pages/dashboard/team/invite.astro
Normal file
68
src/pages/dashboard/team/invite.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { members } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's membership to check if they're admin
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
---
|
||||
|
||||
<DashboardLayout title="Invite Team Member - Zamaan">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Invite Team Member</h1>
|
||||
|
||||
<form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<span>The user must already have an account. They'll be added to your organization.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text">Email Address</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="user@example.com"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="role">
|
||||
<span class="label-text">Role</span>
|
||||
</label>
|
||||
<select id="role" name="role" class="select select-bordered" required>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Members can track time. Admins can manage team and clients.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/team" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Invite Member</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
105
src/pages/dashboard/team/settings.astro
Normal file
105
src/pages/dashboard/team/settings.astro
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { categories, members } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's membership to check if they're admin
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
|
||||
// Get all categories for the organization
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
---
|
||||
|
||||
<DashboardLayout title="Team Settings - Zamaan">
|
||||
<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" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Team Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Categories Section -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
Work Categories
|
||||
</h2>
|
||||
<a href="/dashboard/team/settings/categories/new" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
Add Category
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Categories help organize time tracking by type of work. All team members use the same categories.
|
||||
</p>
|
||||
|
||||
{allCategories.length === 0 ? (
|
||||
<div class="alert alert-info">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<div>
|
||||
<div class="font-bold">No categories yet</div>
|
||||
<div class="text-sm">Create your first category to start organizing time entries.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{allCategories.map(category => (
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{category.color && (
|
||||
<span class="w-4 h-4 rounded-full flex-shrink-0" style={`background-color: ${category.color}`}></span>
|
||||
)}
|
||||
<div class="flex-grow min-w-0">
|
||||
<h3 class="font-semibold truncate">{category.name}</h3>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/dashboard/team/settings/categories/${category.id}/edit`}
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
87
src/pages/dashboard/team/settings/categories/[id]/edit.astro
Normal file
87
src/pages/dashboard/team/settings/categories/[id]/edit.astro
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import DashboardLayout from '../../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../../db';
|
||||
import { categories, members } from '../../../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
const { id } = Astro.params;
|
||||
|
||||
// Get user's membership
|
||||
const userMembership = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||
if (!isAdmin) return Astro.redirect('/dashboard/team/settings');
|
||||
|
||||
// Get category
|
||||
const category = await db.select()
|
||||
.from(categories)
|
||||
.where(and(
|
||||
eq(categories.id, id!),
|
||||
eq(categories.organizationId, userMembership.organizationId)
|
||||
))
|
||||
.get();
|
||||
|
||||
if (!category) return Astro.redirect('/dashboard/team/settings');
|
||||
---
|
||||
|
||||
<DashboardLayout title="Edit Category - Zamaan">
|
||||
<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">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Edit Category</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action={`/api/categories/${id}/update`} class="card bg-base-200 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="name">
|
||||
<span class="label-text font-medium">Category Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={category.name}
|
||||
placeholder="Development"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="color">
|
||||
<span class="label-text font-medium">Color (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
name="color"
|
||||
value={category.color || '#3b82f6'}
|
||||
class="input input-bordered w-full h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between mt-6">
|
||||
<form method="POST" action={`/api/categories/${id}/delete`}>
|
||||
<button type="submit" class="btn btn-error btn-outline">Delete Category</button>
|
||||
</form>
|
||||
<div class="flex gap-2">
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
53
src/pages/dashboard/team/settings/categories/new.astro
Normal file
53
src/pages/dashboard/team/settings/categories/new.astro
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import DashboardLayout from '../../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
---
|
||||
|
||||
<DashboardLayout title="New Category - Zamaan">
|
||||
<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">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Add New Category</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/api/categories/create" class="card bg-base-200 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="name">
|
||||
<span class="label-text font-medium">Category Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Development"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="color">
|
||||
<span class="label-text font-medium">Color (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="color"
|
||||
name="color"
|
||||
class="input input-bordered w-full h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Category</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
375
src/pages/dashboard/tracker.astro
Normal file
375
src/pages/dashboard/tracker.astro
Normal file
@@ -0,0 +1,375 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
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';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get user's first organization
|
||||
const userOrg = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.get();
|
||||
|
||||
if (!userOrg) return Astro.redirect('/dashboard');
|
||||
|
||||
// Get all clients for the organization
|
||||
const allClients = await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, userOrg.organizationId))
|
||||
.all();
|
||||
|
||||
// Get all categories for the organization
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userOrg.organizationId))
|
||||
.all();
|
||||
|
||||
// Get all tags for the organization
|
||||
const allTags = await db.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.organizationId, userOrg.organizationId))
|
||||
.all();
|
||||
|
||||
// Parse query parameters for filtering, sorting, and pagination
|
||||
const url = new URL(Astro.request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const filterClient = url.searchParams.get('client') || '';
|
||||
const filterCategory = url.searchParams.get('category') || '';
|
||||
const filterStatus = url.searchParams.get('status') || ''; // completed, running, all
|
||||
const sortBy = url.searchParams.get('sort') || 'start-desc'; // start-desc, start-asc, duration-desc, duration-asc
|
||||
const searchTerm = url.searchParams.get('search') || '';
|
||||
|
||||
// Build query conditions
|
||||
const conditions = [eq(timeEntries.organizationId, userOrg.organizationId)];
|
||||
|
||||
if (filterClient) {
|
||||
conditions.push(eq(timeEntries.clientId, filterClient));
|
||||
}
|
||||
|
||||
if (filterCategory) {
|
||||
conditions.push(eq(timeEntries.categoryId, filterCategory));
|
||||
}
|
||||
|
||||
if (filterStatus === 'completed') {
|
||||
conditions.push(sql`${timeEntries.endTime} IS NOT NULL`);
|
||||
} else if (filterStatus === 'running') {
|
||||
conditions.push(sql`${timeEntries.endTime} IS NULL`);
|
||||
}
|
||||
|
||||
if (searchTerm) {
|
||||
conditions.push(like(timeEntries.description, `%${searchTerm}%`));
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(timeEntries)
|
||||
.where(and(...conditions))
|
||||
.get();
|
||||
|
||||
const totalPages = Math.ceil((totalCount?.count || 0) / pageSize);
|
||||
|
||||
// Build order by
|
||||
let orderBy;
|
||||
switch (sortBy) {
|
||||
case 'start-asc':
|
||||
orderBy = asc(timeEntries.startTime);
|
||||
break;
|
||||
case 'duration-desc':
|
||||
orderBy = desc(sql`(CASE WHEN ${timeEntries.endTime} IS NULL THEN 0 ELSE ${timeEntries.endTime} - ${timeEntries.startTime} END)`);
|
||||
break;
|
||||
case 'duration-asc':
|
||||
orderBy = asc(sql`(CASE WHEN ${timeEntries.endTime} IS NULL THEN 0 ELSE ${timeEntries.endTime} - ${timeEntries.startTime} END)`);
|
||||
break;
|
||||
default: // start-desc
|
||||
orderBy = desc(timeEntries.startTime);
|
||||
}
|
||||
|
||||
const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
category: categories,
|
||||
user: users,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
const runningEntry = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.where(and(
|
||||
eq(timeEntries.userId, user.id),
|
||||
sql`${timeEntries.endTime} IS NULL`
|
||||
))
|
||||
.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`;
|
||||
}
|
||||
|
||||
// Generate pagination page numbers
|
||||
function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
||||
const pages: number[] = [];
|
||||
const numPagesToShow = Math.min(5, totalPages);
|
||||
|
||||
for (let i = 0; i < numPagesToShow; i++) {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
pages.push(pageNum);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
const paginationPages = getPaginationPages(page, totalPages);
|
||||
---
|
||||
|
||||
<DashboardLayout title="Time Tracker - Zamaan">
|
||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
||||
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning mb-6">
|
||||
<span>You need to create a client before tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||
</div>
|
||||
) : allCategories.length === 0 ? (
|
||||
<div class="alert alert-warning mb-6">
|
||||
<span>You need to create a category before tracking time.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||
</div>
|
||||
) : (
|
||||
<Timer
|
||||
client:load
|
||||
initialRunningEntry={runningEntry ? {
|
||||
startTime: runningEntry.entry.startTime.getTime(),
|
||||
description: runningEntry.entry.description,
|
||||
clientId: runningEntry.entry.clientId,
|
||||
categoryId: runningEntry.entry.categoryId,
|
||||
} : null}
|
||||
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
|
||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<!-- Filters and Search -->
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Search</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search descriptions..."
|
||||
class="input input-bordered"
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Clients</option>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={filterClient === client.id}>
|
||||
{client.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={filterCategory === category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Status</span>
|
||||
</label>
|
||||
<select name="status" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="" selected={filterStatus === ''}>All Entries</option>
|
||||
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
||||
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Sort By</span>
|
||||
</label>
|
||||
<select name="sort" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
|
||||
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
||||
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
||||
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<div class="form-control md:col-span-2 lg:col-span-5">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
||||
Time Entries ({totalCount?.count || 0} total)
|
||||
</h2>
|
||||
{(filterClient || filterCategory || filterStatus || searchTerm) && (
|
||||
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost">
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
Clear Filters
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th>Member</th>
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Duration</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(({ entry, client, category, user: entryUser }) => (
|
||||
<tr>
|
||||
<td>{client?.name || 'Unknown'}</td>
|
||||
<td>
|
||||
{category ? (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${category.color}`}></span>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>{entry.description || '-'}</td>
|
||||
<td>{entryUser?.name || 'Unknown'}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
{entry.startTime.toLocaleDateString()}<br/>
|
||||
<span class="text-xs opacity-50">
|
||||
{entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">
|
||||
{entry.endTime ? (
|
||||
<>
|
||||
{entry.endTime.toLocaleDateString()}<br/>
|
||||
<span class="text-xs opacity-50">
|
||||
{entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="badge badge-success">Running</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="font-mono">{formatDuration(entry.startTime, entry.endTime)}</td>
|
||||
<td>
|
||||
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick="return confirm('Are you sure you want to delete this entry?')"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{totalPages > 1 && (
|
||||
<div class="flex justify-center items-center gap-2 mt-6">
|
||||
<a
|
||||
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm ${page === 1 ? 'btn-disabled' : ''}`}
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
|
||||
Previous
|
||||
</a>
|
||||
|
||||
<div class="flex gap-1">
|
||||
{paginationPages.map(pageNum => (
|
||||
<a
|
||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm ${page === pageNum ? 'btn-active' : ''}`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm ${page === totalPages ? 'btn-disabled' : ''}`}
|
||||
>
|
||||
Next
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
Reference in New Issue
Block a user