This commit is contained in:
2
drizzle/0006_good_malcolm_colcord.sql
Normal file
2
drizzle/0006_good_malcolm_colcord.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE `categories`;--> statement-breakpoint
|
||||
ALTER TABLE `time_entries` DROP COLUMN `category_id`;
|
||||
1266
drizzle/meta/0006_snapshot.json
Normal file
1266
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user