Switch to tags
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled

This commit is contained in:
2026-01-20 11:09:09 -07:00
parent de5b1063b7
commit ad7dc18780
22 changed files with 1516 additions and 1057 deletions

View File

@@ -0,0 +1,2 @@
DROP TABLE `categories`;--> statement-breakpoint
ALTER TABLE `time_entries` DROP COLUMN `category_id`;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1768931251965,
"tag": "0005_fair_skreet",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1768932542464,
"tag": "0006_good_malcolm_colcord",
"breakpoints": true
}
]
}

View File

@@ -1,64 +0,0 @@
<template>
<div style="position: relative; height: 100%; width: 100%;">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Doughnut } from 'vue-chartjs';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
DoughnutController
} from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, DoughnutController);
interface CategoryData {
name: string;
totalTime: number;
color: string;
}
const props = defineProps<{
categories: CategoryData[];
}>();
const chartData = computed(() => ({
labels: props.categories.map(c => c.name),
datasets: [{
data: props.categories.map(c => c.totalTime),
backgroundColor: props.categories.map(c => c.color || '#3b82f6'),
borderWidth: 2,
borderColor: '#1e293b',
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
color: '#e2e8f0',
padding: 15,
font: { size: 12 }
}
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw / (1000 * 60));
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${context.label}: ${hours}h ${mins}m`;
}
}
}
}
};
</script>

View File

@@ -3,7 +3,6 @@ import { ref } from "vue";
const props = defineProps<{
clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[];
}>();
@@ -13,7 +12,6 @@ const emit = defineEmits<{
const description = ref("");
const selectedClientId = ref("");
const selectedCategoryId = ref("");
const selectedTags = ref<string[]>([]);
const startDate = ref("");
const startTime = ref("");
@@ -53,10 +51,6 @@ function validateForm(): string | null {
return "Please select a client";
}
if (!selectedCategoryId.value) {
return "Please select a category";
}
if (!startDate.value || !startTime.value) {
return "Please enter start date and time";
}
@@ -101,7 +95,6 @@ async function submitManualEntry() {
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
startTime: startDateTime,
endTime: endDateTime,
tags: selectedTags.value,
@@ -119,7 +112,6 @@ async function submitManualEntry() {
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
startDate.value = today;
endDate.value = today;
@@ -144,7 +136,6 @@ async function submitManualEntry() {
function clearForm() {
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
startDate.value = today;
endDate.value = today;
@@ -208,49 +199,22 @@ function clearForm() {
<span>{{ error }}</span>
</div>
<!-- Client and Category Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2 font-medium" for="manual-client">
Client <span class="label-text-alt text-error">*</span>
</label>
<select
id="manual-client"
v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
>
<option value="">Select a client...</option>
<option
v-for="client in clients"
:key="client.id"
:value="client.id"
>
{{ client.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label pb-2 font-medium" for="manual-category">
Category <span class="label-text-alt text-error">*</span>
</label>
<select
id="manual-category"
v-model="selectedCategoryId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
>
<option value="">Select a category...</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
</div>
<!-- Client Row -->
<div class="form-control">
<label class="label pb-2 font-medium" for="manual-client">
Client <span class="label-text-alt text-error">*</span>
</label>
<select
id="manual-client"
v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
>
<option value="">Select a client...</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
</div>
<!-- Start Date and Time -->

View File

@@ -7,10 +7,8 @@ const props = defineProps<{
startTime: number;
description: string | null;
clientId: string;
categoryId: string;
} | null;
clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[];
}>();
@@ -19,7 +17,6 @@ const startTime = ref<number | null>(null);
const elapsedTime = ref(0);
const description = ref("");
const selectedClientId = ref("");
const selectedCategoryId = ref("");
const selectedTags = ref<string[]>([]);
let interval: ReturnType<typeof setInterval> | null = null;
@@ -63,7 +60,6 @@ onMounted(() => {
startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || "";
selectedClientId.value = props.initialRunningEntry.clientId;
selectedCategoryId.value = props.initialRunningEntry.categoryId;
elapsedTime.value = Date.now() - startTime.value;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
@@ -81,18 +77,12 @@ async function startTimer() {
return;
}
if (!selectedCategoryId.value) {
alert("Please select a category");
return;
}
const res = await fetch("/api/time-entries/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
tags: selectedTags.value,
}),
});
@@ -119,7 +109,6 @@ async function stopTimer() {
startTime.value = null;
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
window.location.reload();
}
@@ -131,49 +120,22 @@ async function stopTimer() {
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200"
>
<div class="card-body gap-6">
<!-- Client and Description Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2 font-medium" for="timer-client">
Client
</label>
<select
id="timer-client"
v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isRunning"
>
<option value="">Select a client...</option>
<option
v-for="client in clients"
:key="client.id"
:value="client.id"
>
{{ client.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label pb-2 font-medium" for="timer-category">
Category
</label>
<select
id="timer-category"
v-model="selectedCategoryId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isRunning"
>
<option value="">Select a category...</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
</div>
<!-- Client Row -->
<div class="form-control">
<label class="label pb-2 font-medium" for="timer-client">
Client
</label>
<select
id="timer-client"
v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isRunning"
>
<option value="">Select a client...</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
</div>
<!-- Description Row -->

View File

@@ -97,30 +97,6 @@ export const clients = sqliteTable(
}),
);
export const categories = sqliteTable(
"categories",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
color: text("color"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("categories_organization_id_idx").on(
table.organizationId,
),
}),
);
export const timeEntries = sqliteTable(
"time_entries",
{
@@ -130,7 +106,6 @@ export const timeEntries = sqliteTable(
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(),
categoryId: text("category_id").notNull(),
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
endTime: integer("end_time", { mode: "timestamp" }),
description: text("description"),
@@ -153,10 +128,6 @@ export const timeEntries = sqliteTable(
columns: [table.clientId],
foreignColumns: [clients.id],
}),
categoryFk: foreignKey({
columns: [table.categoryId],
foreignColumns: [categories.id],
}),
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
organizationIdIdx: index("time_entries_organization_id_idx").on(
table.organizationId,

View File

@@ -1,49 +1,28 @@
import { db } from "../db";
import { clients, categories, tags as tagsTable } from "../db/schema";
import { clients, tags as tagsTable } from "../db/schema";
import { eq, and, inArray } from "drizzle-orm";
export async function validateTimeEntryResources({
organizationId,
clientId,
categoryId,
tagIds,
}: {
organizationId: string;
clientId: string;
categoryId: string;
tagIds?: string[];
}) {
const [client, category] = await Promise.all([
db
.select()
.from(clients)
.where(
and(
eq(clients.id, clientId),
eq(clients.organizationId, organizationId),
),
)
.get(),
db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, organizationId),
),
)
.get(),
]);
const client = await db
.select()
.from(clients)
.where(
and(eq(clients.id, clientId), eq(clients.organizationId, organizationId)),
)
.get();
if (!client) {
return { valid: false, error: "Invalid client" };
}
if (!category) {
return { valid: false, error: "Invalid category" };
}
if (tagIds && tagIds.length > 0) {
const validTags = await db
.select()

View File

@@ -1,67 +0,0 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
redirectTo = formData.get("redirectTo")?.toString();
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response("No organization found", { status: 400 });
}
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdmin) {
return new Response("Forbidden", { status: 403 });
}
const hasEntries = await db
.select()
.from(timeEntries)
.where(eq(timeEntries.categoryId, id!))
.get();
if (hasEntries) {
return new Response("Cannot delete category with time entries", {
status: 400,
});
}
await db
.delete(categories)
.where(
and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId),
),
);
if (locals.scopes) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,72 +0,0 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
redirectTo = formData.get("redirectTo")?.toString();
}
if (!name) {
return new Response("Name is required", { status: 400 });
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response("No organization found", { status: 400 });
}
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdmin) {
return new Response("Forbidden", { status: 403 });
}
await db
.update(categories)
.set({
name,
color: color || null,
})
.where(
and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId),
),
);
if (locals.scopes) {
return new Response(
JSON.stringify({ success: true, id, name, color: color || null }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,59 +0,0 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { categories, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
redirectTo = formData.get("redirectTo")?.toString();
}
if (!name) {
return new Response("Name is required", { status: 400 });
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response("No organization found", { status: 400 });
}
const id = nanoid();
await db.insert(categories).values({
id,
organizationId: userOrg.organizationId,
name,
color: color || null,
});
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, color: color || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,102 +1,100 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import {
invoices,
invoiceItems,
clients,
organizations,
members,
} from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { renderToStream } from "@ceereals/vue-pdf";
import { db } from "../../../../db";
import { invoices, invoiceItems, clients, organizations, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { createInvoiceDocument } from "../../../../pdf/generateInvoicePDF";
export const GET: APIRoute = async ({ params, locals }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
if (!id) {
return new Response("Invoice ID is required", { status: 400 });
}
// Fetch invoice with related data
const invoiceResult = await db.select({
invoice: invoices,
client: clients,
organization: organizations,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
.where(eq(invoices.id, id))
.get();
if (!invoiceResult) {
return new Response("Invoice not found", { status: 404 });
}
const { invoice, client, organization } = invoiceResult;
// Verify membership
const membership = await db.select()
.from(members)
.where(and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
))
.get();
if (!membership) {
return new Response("Not authorized", { status: 403 });
}
// Fetch items
const items = await db.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
try {
const { id } = params;
const user = locals.user;
const document = createInvoiceDocument({
invoice: {
...invoice,
notes: invoice.notes || null,
// Ensure null safety for optional fields that might be undefined in some runtimes depending on driver
discountValue: invoice.discountValue ?? null,
discountType: invoice.discountType ?? null,
discountAmount: invoice.discountAmount ?? null,
taxRate: invoice.taxRate ?? null,
},
items,
client: {
name: client?.name || "Deleted Client",
email: client?.email || null,
street: client?.street || null,
city: client?.city || null,
state: client?.state || null,
zip: client?.zip || null,
country: client?.country || null,
},
organization: {
name: organization.name,
street: organization.street || null,
city: organization.city || null,
state: organization.state || null,
zip: organization.zip || null,
country: organization.country || null,
logoUrl: organization.logoUrl || null,
}
});
if (!user || !id) {
return new Response("Unauthorized", { status: 401 });
}
const stream = await renderToStream(document);
const invoiceResult = await db
.select({
invoice: invoices,
client: clients,
organization: organizations,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
.where(eq(invoices.id, id))
.get();
if (!invoiceResult) {
return new Response("Invoice not found", { status: 404 });
}
const { invoice, client, organization } = invoiceResult;
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId),
),
)
.get();
if (!membership) {
return new Response("Forbidden", { status: 403 });
}
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
if (!client) {
return new Response("Client not found", { status: 404 });
}
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const pdfDocument = createInvoiceDocument({
invoice,
items,
client,
organization,
});
const stream = await renderToStream(pdfDocument);
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
return new Response(stream as any, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (pdfError) {
// Restore console.log on error
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
throw pdfError;
}
return new Response(stream, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`,
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
return new Response("Failed to generate PDF", { status: 500 });
}
};

View File

@@ -1,72 +1,96 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, users, clients, categories } from '../../../db/schema';
import { eq, and, gte, lte, desc } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
timeEntries,
members,
users,
clients,
tags,
timeEntryTags,
} from "../../../db/schema";
import { eq, and, gte, lte, desc, inArray } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals, cookies }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
// Get current team from cookie
const currentTeamId = cookies.get('currentTeamId')?.value;
const currentTeamId = cookies.get("currentTeamId")?.value;
const userMemberships = await db.select()
const userMemberships = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) {
return new Response('No organization found', { status: 404 });
return new Response("No organization found", { status: 404 });
}
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
const url = new URL(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';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const selectedMemberId = url.searchParams.get("member") || "";
const selectedClientId = url.searchParams.get("client") || "";
const timeRange = url.searchParams.get("range") || "week";
const customFrom = url.searchParams.get("from");
const customTo = url.searchParams.get("to");
const now = new Date();
let startDate = new Date();
let endDate = new Date();
switch (timeRange) {
case 'today':
case "today":
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
break;
case 'week':
case "week":
startDate.setDate(now.getDate() - 7);
break;
case 'month':
case "month":
startDate.setMonth(now.getMonth() - 1);
break;
case 'mtd':
case "mtd":
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'ytd':
case "ytd":
startDate = new Date(now.getFullYear(), 0, 1);
break;
case 'last-month':
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;
case 'custom':
case "custom":
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
const parts = customFrom.split("-");
startDate = new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
0,
0,
0,
0,
);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
const parts = customTo.split("-");
endDate = new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
23,
59,
59,
999,
);
}
break;
}
@@ -81,31 +105,58 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
})
const entries = await db
.select({
entry: timeEntries,
user: users,
client: clients,
})
.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();
// Fetch tags for these entries
const entryIds = entries.map((e) => e.entry.id);
const tagsMap = new Map<string, string[]>();
if (entryIds.length > 0) {
const entryTags = await db
.select({
entryId: timeEntryTags.timeEntryId,
tagName: tags.name,
})
.from(timeEntryTags)
.innerJoin(tags, eq(timeEntryTags.tagId, tags.id))
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.all();
for (const tag of entryTags) {
if (!tagsMap.has(tag.entryId)) {
tagsMap.set(tag.entryId, []);
}
tagsMap.get(tag.entryId)!.push(tag.tagName);
}
}
// Generate CSV
const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description'];
const rows = entries.map(e => {
const headers = [
"Date",
"Start Time",
"End Time",
"Duration (h)",
"Member",
"Client",
"Tags",
"Description",
];
const rows = entries.map((e) => {
const start = e.entry.startTime;
const end = e.entry.endTime;
@@ -114,24 +165,26 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
}
const tagsStr = tagsMap.get(e.entry.id)?.join("; ") || "";
return [
start.toLocaleDateString(),
start.toLocaleTimeString(),
end ? end.toLocaleTimeString() : '',
end ? duration.toFixed(2) : 'Running',
`"${(e.user.name || '').replace(/"/g, '""')}"`,
`"${(e.client.name || '').replace(/"/g, '""')}"`,
`"${(e.category.name || '').replace(/"/g, '""')}"`,
`"${(e.entry.description || '').replace(/"/g, '""')}"`
].join(',');
end ? end.toLocaleTimeString() : "",
end ? duration.toFixed(2) : "Running",
`"${(e.user.name || "").replace(/"/g, '""')}"`,
`"${(e.client.name || "").replace(/"/g, '""')}"`,
`"${tagsStr.replace(/"/g, '""')}"`,
`"${(e.entry.description || "").replace(/"/g, '""')}"`,
].join(",");
});
const csvContent = [headers.join(','), ...rows].join('\n');
const csvContent = [headers.join(","), ...rows].join("\n");
return new Response(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.csv"`,
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="time-entries-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}.csv"`,
},
});
};

View File

@@ -17,7 +17,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
const body = await request.json();
const { description, clientId, categoryId, startTime, endTime, tags } = body;
const { description, clientId, startTime, endTime, tags } = body;
// Validation
if (!clientId) {
@@ -27,13 +27,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
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,
@@ -81,7 +74,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
const resourceValidation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
categoryId,
tagIds: Array.isArray(tags) ? tags : undefined,
});
@@ -101,7 +93,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
startTime: startDate,
endTime: endDate,
description: description || null,

View File

@@ -11,17 +11,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
const body = await request.json();
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 });
}
if (!categoryId) {
return new Response("Category is required", { status: 400 });
}
const runningEntry = await db
.select()
.from(timeEntries)
@@ -47,7 +42,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
const validation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
categoryId,
tagIds: tags,
});
@@ -63,7 +57,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
startTime,
description,
isManual: false,

View File

@@ -1,62 +0,0 @@
---
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 current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.all();
---
<DashboardLayout title="Categories - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Categories</h1>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>
</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>

View File

@@ -1,99 +0,0 @@
---
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 current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/categories');
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/categories');
---
<DashboardLayout title="Edit Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/categories" 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>
<div class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<form id="update-form" method="POST" action={`/api/categories/${id}/update`}>
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
<div class="form-control">
<label class="label pb-2 font-medium" for="name">
Category Name
</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 mt-4">
<label class="label pb-2 font-medium" for="color">
Color (optional)
</label>
<input
type="color"
id="color"
name="color"
value={category.color || '#3b82f6'}
class="input input-bordered w-full h-12"
/>
</div>
</form>
<div class="card-actions justify-between mt-6">
<form method="POST" action={`/api/categories/${id}/delete`} onsubmit="return confirm('Are you sure you want to delete this category?');">
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
<button type="submit" class="btn btn-error btn-outline">Delete Category</button>
</form>
<div class="flex gap-2">
<a href="/dashboard/categories" class="btn btn-ghost">Cancel</a>
<button type="submit" form="update-form" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -1,54 +0,0 @@
---
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 - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/categories" 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">
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
<div class="card-body">
<div class="form-control">
<label class="label pb-2 font-medium" for="name">
Category Name
</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 font-medium" for="color">
Color (optional)
</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/categories" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Category</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -2,7 +2,7 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { categories, members, organizations, tags } from '../../../db/schema';
import { members, organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
@@ -35,10 +35,7 @@ const organization = await db.select()
if (!organization) return Astro.redirect('/dashboard');
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, orgId))
.all();
const allTags = await db.select()
.from(tags)
@@ -415,60 +412,6 @@ const successType = url.searchParams.get('success');
</form>
</dialog>
<!-- 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 shrink-0" style={`background-color: ${category.color}`}></span>
)}
<div class="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>
</DashboardLayout>

View File

@@ -1,93 +0,0 @@
---
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 current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team/settings');
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 - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<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 font-medium" for="name">
Category Name
</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 font-medium" for="color">
Color (optional)
</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>

View File

@@ -1,53 +0,0 @@
---
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 - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<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 font-medium" for="name">
Category Name
</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 font-medium" for="color">
Color (optional)
</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>

View File

@@ -4,7 +4,7 @@ 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 { timeEntries, clients, members, tags, timeEntryTags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime';
@@ -33,11 +33,6 @@ const allClients = await db.select()
.where(eq(clients.organizationId, organizationId))
.all();
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, organizationId))
.all();
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, organizationId))
@@ -50,7 +45,7 @@ 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') || '';
const filterType = url.searchParams.get('type') || '';
const sortBy = url.searchParams.get('sort') || 'start-desc';
@@ -62,10 +57,6 @@ 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') {
@@ -107,12 +98,10 @@ switch (sortBy) {
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)
@@ -169,12 +158,6 @@ const paginationPages = getPaginationPages(page, totalPages);
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="flex-1 text-center sm:text-left">You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
</div>
) : (
<Timer
client:load
@@ -182,10 +165,8 @@ const paginationPages = getPaginationPages(page, totalPages);
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 }))}
/>
)}
@@ -199,17 +180,10 @@ const paginationPages = getPaginationPages(page, totalPages);
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="flex-1 text-center sm:text-left">You need to create a category before adding time entries.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
</div>
) : (
<ManualEntry
client:idle
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 }))}
/>
)}
@@ -252,19 +226,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</select>
</div>
<div class="form-control">
<label class="label font-medium" for="tracker-category">
Category
</label>
<select id="tracker-category" 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 w-full" 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 font-medium" for="tracker-status">
@@ -318,7 +280,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
Time Entries ({totalCount?.count || 0} total)
</h2>
{(filterClient || filterCategory || filterStatus || filterType || searchTerm) && (
{(filterClient || 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
@@ -331,7 +293,6 @@ const paginationPages = getPaginationPages(page, totalPages);
<tr class="bg-base-300/30">
<th>Type</th>
<th>Client</th>
<th>Category</th>
<th>Description</th>
<th>Member</th>
<th>Start Time</th>
@@ -341,7 +302,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</tr>
</thead>
<tbody>
{entries.map(({ entry, client, category, user: entryUser }) => (
{entries.map(({ entry, client, user: entryUser }) => (
<tr class="hover:bg-base-300/20 transition-colors">
<td>
{entry.isManual ? (
@@ -357,14 +318,6 @@ const paginationPages = getPaginationPages(page, totalPages);
)}
</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 shadow-sm" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
</div>
) : '-'}
</td>
<td class="text-base-content/80">{entry.description || '-'}</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap">
@@ -407,7 +360,7 @@ const paginationPages = getPaginationPages(page, totalPages);
{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}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${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" />
@@ -417,7 +370,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<div class="flex gap-1">
{paginationPages.map(pageNum => (
<a
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${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}
@@ -426,7 +379,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
<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}` : ''}`}
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${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