Adding manual entries + UI cleanup
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { db } from '../../db';
|
||||
import { siteSettings, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -36,7 +37,7 @@ const allUsers = await db.select().from(users).all();
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Site Settings</h2>
|
||||
|
||||
|
||||
<form method="POST" action="/api/admin/settings">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
@@ -44,10 +45,10 @@ const allUsers = await db.select().from(users).all();
|
||||
<div class="font-semibold">Allow New Registrations</div>
|
||||
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="registration_enabled"
|
||||
class="toggle toggle-primary"
|
||||
<input
|
||||
type="checkbox"
|
||||
name="registration_enabled"
|
||||
class="toggle toggle-primary"
|
||||
checked={registrationEnabled}
|
||||
/>
|
||||
</label>
|
||||
@@ -79,11 +80,7 @@ const allUsers = await db.select().from(users).all();
|
||||
<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>{u.name.charAt(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Avatar name={u.name} />
|
||||
<div class="font-bold">{u.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
195
src/pages/api/time-entries/manual.ts
Normal file
195
src/pages/api/time-entries/manual.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { description, clientId, categoryId, startTime, endTime, tags } = body;
|
||||
|
||||
// Validation
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Client is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Category is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Start time is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!endTime) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'End time is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = new Date(startTime);
|
||||
const endDate = new Date(endTime);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid date format' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (endDate <= startDate) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'End time must be after start time' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's organization
|
||||
const member = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, locals.user.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!member) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No organization found' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Verify category belongs to organization
|
||||
const category = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, member.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!category) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid category' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Verify client belongs to organization
|
||||
const client = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.id, clientId),
|
||||
eq(clients.organizationId, member.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!client) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid client' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
|
||||
try {
|
||||
// Insert the manual time entry
|
||||
await db.insert(timeEntries).values({
|
||||
id,
|
||||
userId: locals.user.id,
|
||||
organizationId: member.organizationId,
|
||||
clientId,
|
||||
categoryId,
|
||||
startTime: startDate,
|
||||
endTime: endDate,
|
||||
description: description || null,
|
||||
isManual: true,
|
||||
});
|
||||
|
||||
// Insert tags if provided
|
||||
if (tags && Array.isArray(tags) && tags.length > 0) {
|
||||
await db.insert(timeEntryTags).values(
|
||||
tags.map((tagId: string) => ({
|
||||
timeEntryId: id,
|
||||
tagId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
id,
|
||||
startTime: startDate.toISOString(),
|
||||
endTime: endDate.toISOString(),
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating manual time entry:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create time entry' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,51 +1,66 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import {
|
||||
timeEntries,
|
||||
members,
|
||||
timeEntryTags,
|
||||
categories,
|
||||
} from "../../../db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const description = body.description || '';
|
||||
const description = body.description || "";
|
||||
const clientId = body.clientId;
|
||||
const categoryId = body.categoryId;
|
||||
const tags = body.tags || [];
|
||||
|
||||
if (!clientId) {
|
||||
return new Response('Client is required', { status: 400 });
|
||||
return new Response("Client is required", { status: 400 });
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return new Response('Category is required', { status: 400 });
|
||||
return new Response("Category is required", { status: 400 });
|
||||
}
|
||||
|
||||
const runningEntry = await db.select().from(timeEntries).where(
|
||||
and(
|
||||
eq(timeEntries.userId, locals.user.id),
|
||||
isNull(timeEntries.endTime)
|
||||
const runningEntry = await db
|
||||
.select()
|
||||
.from(timeEntries)
|
||||
.where(
|
||||
and(eq(timeEntries.userId, locals.user.id), isNull(timeEntries.endTime)),
|
||||
)
|
||||
).get();
|
||||
.get();
|
||||
|
||||
if (runningEntry) {
|
||||
return new Response('Timer already running', { status: 400 });
|
||||
return new Response("Timer already running", { status: 400 });
|
||||
}
|
||||
|
||||
const member = await db.select().from(members).where(eq(members.userId, locals.user.id)).limit(1).get();
|
||||
const member = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, locals.user.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (!member) {
|
||||
return new Response('No organization found', { status: 400 });
|
||||
return new Response("No organization found", { status: 400 });
|
||||
}
|
||||
|
||||
const category = await db.select().from(categories).where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, member.organizationId)
|
||||
const category = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, member.organizationId),
|
||||
),
|
||||
)
|
||||
).get();
|
||||
.get();
|
||||
|
||||
if (!category) {
|
||||
return new Response('Invalid category', { status: 400 });
|
||||
return new Response("Invalid category", { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = new Date();
|
||||
@@ -59,6 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
categoryId,
|
||||
startTime,
|
||||
description,
|
||||
isManual: false,
|
||||
});
|
||||
|
||||
if (tags.length > 0) {
|
||||
@@ -66,7 +82,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
tags.map((tagId: string) => ({
|
||||
timeEntryId: id,
|
||||
tagId,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { members, users } from '../../db/schema';
|
||||
@@ -70,11 +71,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
<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>
|
||||
<Avatar name={teamUser.name} />
|
||||
<div>
|
||||
<div class="font-bold">{teamUser.name}</div>
|
||||
{teamUser.id === user.id && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
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, timeEntryTags, categories, users } from '../../db/schema';
|
||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||
@@ -21,7 +22,7 @@ const userMemberships = await db.select()
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
|
||||
@@ -51,6 +52,7 @@ const offset = (page - 1) * pageSize;
|
||||
const filterClient = url.searchParams.get('client') || '';
|
||||
const filterCategory = url.searchParams.get('category') || '';
|
||||
const filterStatus = url.searchParams.get('status') || '';
|
||||
const filterType = url.searchParams.get('type') || '';
|
||||
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
||||
const searchTerm = url.searchParams.get('search') || '';
|
||||
|
||||
@@ -74,6 +76,12 @@ if (searchTerm) {
|
||||
conditions.push(like(timeEntries.description, `%${searchTerm}%`));
|
||||
}
|
||||
|
||||
if (filterType === 'manual') {
|
||||
conditions.push(eq(timeEntries.isManual, true));
|
||||
} else if (filterType === 'timed') {
|
||||
conditions.push(eq(timeEntries.isManual, false));
|
||||
}
|
||||
|
||||
const totalCount = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(timeEntries)
|
||||
.where(and(...conditions))
|
||||
@@ -127,7 +135,7 @@ const runningEntry = await db.select({
|
||||
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) {
|
||||
@@ -141,7 +149,7 @@ function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
||||
}
|
||||
pages.push(pageNum);
|
||||
}
|
||||
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -150,45 +158,77 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
|
||||
<DashboardLayout title="Time Tracker - Chronus">
|
||||
<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">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<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">
|
||||
<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 }))}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<span>You need to create a client before adding time entries.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||
</div>
|
||||
) : allCategories.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<span>You need to create a category before adding time entries.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||
</div>
|
||||
) : (
|
||||
<ManualEntry
|
||||
client:load
|
||||
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 }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 }))}
|
||||
/>
|
||||
)}
|
||||
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
|
||||
) : null}
|
||||
|
||||
<!-- Filters and Search -->
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
||||
<div class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 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"
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search descriptions..."
|
||||
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
@@ -197,7 +237,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<select name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<option value="">All Clients</option>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={filterClient === client.id}>
|
||||
@@ -211,7 +251,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<select name="category" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={filterCategory === category.id}>
|
||||
@@ -225,18 +265,29 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Status</span>
|
||||
</label>
|
||||
<select name="status" class="select select-bordered" onchange="this.form.submit()">
|
||||
<select name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" 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">Entry Type</span>
|
||||
</label>
|
||||
<select name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<option value="" selected={filterType === ''}>All Types</option>
|
||||
<option value="timed" selected={filterType === 'timed'}>Timed</option>
|
||||
<option value="manual" selected={filterType === 'manual'}>Manual</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()">
|
||||
<select name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" 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>
|
||||
@@ -245,8 +296,8 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</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">
|
||||
<div class="form-control md:col-span-2 lg:col-span-6">
|
||||
<button type="submit" class="btn btn-primary shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all">
|
||||
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
@@ -255,24 +306,25 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card bg-base-200/30 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-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">
|
||||
{(filterClient || filterCategory || filterStatus || filterType || searchTerm) && (
|
||||
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost hover:bg-base-300/50 transition-colors">
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
Clear Filters
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<tr class="bg-base-300/30">
|
||||
<th>Type</th>
|
||||
<th>Client</th>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
@@ -285,17 +337,30 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(({ entry, client, category, user: entryUser }) => (
|
||||
<tr>
|
||||
<td>{client?.name || 'Unknown'}</td>
|
||||
<tr class="hover:bg-base-300/20 transition-colors">
|
||||
<td>
|
||||
{entry.isManual ? (
|
||||
<span class="badge badge-info badge-sm gap-1 shadow-sm" title="Manual Entry">
|
||||
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||
Manual
|
||||
</span>
|
||||
) : (
|
||||
<span class="badge badge-success badge-sm gap-1 shadow-sm" title="Timed Entry">
|
||||
<Icon name="heroicons:clock" class="w-3 h-3" />
|
||||
Timed
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="font-medium">{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 class="w-3 h-3 rounded-full shadow-sm" style={`background-color: ${category.color}`}></span>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>{entry.description || '-'}</td>
|
||||
<td class="text-base-content/80">{entry.description || '-'}</td>
|
||||
<td>{entryUser?.name || 'Unknown'}</td>
|
||||
<td class="whitespace-nowrap">
|
||||
{entry.startTime.toLocaleDateString()}<br/>
|
||||
@@ -312,15 +377,15 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="badge badge-success">Running</span>
|
||||
<span class="badge badge-success shadow-sm">Running</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
||||
<td class="font-mono font-semibold text-primary">{formatTimeRange(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"
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-ghost btn-sm text-error hover:bg-error/10 transition-colors"
|
||||
onclick="return confirm('Are you sure you want to delete this entry?')"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
@@ -336,28 +401,28 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<!-- 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' : ''}`}
|
||||
<a
|
||||
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm transition-all ${page === 1 ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
|
||||
>
|
||||
<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' : ''}`}
|
||||
<a
|
||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`}
|
||||
>
|
||||
{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' : ''}`}
|
||||
<a
|
||||
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
|
||||
>
|
||||
Next
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||||
|
||||
Reference in New Issue
Block a user