Adding manual entries + UI cleanup
This commit is contained in:
15
src/components/Avatar.astro
Normal file
15
src/components/Avatar.astro
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, class: className } = Astro.props;
|
||||||
|
const initial = name ? name.charAt(0).toUpperCase() : '?';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={["avatar placeholder", className]}>
|
||||||
|
<div class="bg-primary text-primary-content w-10 rounded-full flex items-center justify-center">
|
||||||
|
<span class="text-lg font-semibold">{initial}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
369
src/components/ManualEntry.vue
Normal file
369
src/components/ManualEntry.vue
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 }[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "entryCreated"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const description = ref("");
|
||||||
|
const selectedClientId = ref("");
|
||||||
|
const selectedCategoryId = ref("");
|
||||||
|
const selectedTags = ref<string[]>([]);
|
||||||
|
const startDate = ref("");
|
||||||
|
const startTime = ref("");
|
||||||
|
const endDate = ref("");
|
||||||
|
const endTime = ref("");
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const success = ref(false);
|
||||||
|
|
||||||
|
// Set default dates to today
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
startDate.value = today;
|
||||||
|
endDate.value = today;
|
||||||
|
|
||||||
|
function toggleTag(tagId: string) {
|
||||||
|
const index = selectedTags.value.indexOf(tagId);
|
||||||
|
if (index > -1) {
|
||||||
|
selectedTags.value.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
selectedTags.value.push(tagId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(start: Date, end: Date): string {
|
||||||
|
const ms = end.getTime() - start.getTime();
|
||||||
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): string | null {
|
||||||
|
if (!selectedClientId.value) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endDate.value || !endTime.value) {
|
||||||
|
return "Please enter end date and time";
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(`${startDate.value}T${startTime.value}`);
|
||||||
|
const end = new Date(`${endDate.value}T${endTime.value}`);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
return "Invalid date or time format";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end <= start) {
|
||||||
|
return "End time must be after start time";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualEntry() {
|
||||||
|
error.value = "";
|
||||||
|
success.value = false;
|
||||||
|
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
error.value = validationError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startDateTime = `${startDate.value}T${startTime.value}`;
|
||||||
|
const endDateTime = `${endDate.value}T${endTime.value}`;
|
||||||
|
|
||||||
|
const res = await fetch("/api/time-entries/manual", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
description: description.value,
|
||||||
|
clientId: selectedClientId.value,
|
||||||
|
categoryId: selectedCategoryId.value,
|
||||||
|
startTime: startDateTime,
|
||||||
|
endTime: endDateTime,
|
||||||
|
tags: selectedTags.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
success.value = true;
|
||||||
|
|
||||||
|
// Calculate duration for success message
|
||||||
|
const start = new Date(startDateTime);
|
||||||
|
const end = new Date(endDateTime);
|
||||||
|
const duration = formatDuration(start, end);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
description.value = "";
|
||||||
|
selectedClientId.value = "";
|
||||||
|
selectedCategoryId.value = "";
|
||||||
|
selectedTags.value = [];
|
||||||
|
startDate.value = today;
|
||||||
|
endDate.value = today;
|
||||||
|
startTime.value = "";
|
||||||
|
endTime.value = "";
|
||||||
|
|
||||||
|
// Emit event and reload after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
emit("entryCreated");
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
error.value = data.error || "Failed to create time entry";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = "An error occurred. Please try again.";
|
||||||
|
console.error("Error creating manual entry:", err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
description.value = "";
|
||||||
|
selectedClientId.value = "";
|
||||||
|
selectedCategoryId.value = "";
|
||||||
|
selectedTags.value = [];
|
||||||
|
startDate.value = today;
|
||||||
|
endDate.value = today;
|
||||||
|
startTime.value = "";
|
||||||
|
endTime.value = "";
|
||||||
|
error.value = "";
|
||||||
|
success.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div class="card-body gap-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-xl font-semibold">Add Manual Entry</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="clearForm"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div v-if="success" class="alert alert-success">
|
||||||
|
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Manual time entry created successfully!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div v-if="error" class="alert alert-error">
|
||||||
|
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<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">
|
||||||
|
<span class="label-text font-medium">Client</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
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">
|
||||||
|
<span class="label-text font-medium">Category</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Date and Time -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">Start Date</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="startDate"
|
||||||
|
type="date"
|
||||||
|
class="input input-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">Start Time</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="startTime"
|
||||||
|
type="time"
|
||||||
|
class="input input-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End Date and Time -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">End Date</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="endDate"
|
||||||
|
type="date"
|
||||||
|
class="input input-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">End Time</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="endTime"
|
||||||
|
type="time"
|
||||||
|
class="input input-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description Row -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">Description</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="description"
|
||||||
|
type="text"
|
||||||
|
placeholder="What did you work on?"
|
||||||
|
class="input input-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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags Section -->
|
||||||
|
<div v-if="tags.length > 0" class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">Tags</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
@click="toggleTag(tag.id)"
|
||||||
|
:class="[
|
||||||
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
|
selectedTags.includes(tag.id)
|
||||||
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
|
: 'badge-outline hover:bg-base-300/50',
|
||||||
|
]"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
@click="submitManualEntry"
|
||||||
|
class="btn btn-primary flex-1 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
<span v-if="isSubmitting" class="loading loading-spinner"></span>
|
||||||
|
{{ isSubmitting ? "Creating..." : "Add Manual Entry" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -127,7 +127,9 @@ async function stopTimer() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
<div
|
||||||
|
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200"
|
||||||
|
>
|
||||||
<div class="card-body gap-6">
|
<div class="card-body gap-6">
|
||||||
<!-- Client and Description Row -->
|
<!-- Client and Description Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@@ -137,7 +139,7 @@ async function stopTimer() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="selectedClientId"
|
v-model="selectedClientId"
|
||||||
class="select select-bordered w-full"
|
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"
|
:disabled="isRunning"
|
||||||
>
|
>
|
||||||
<option value="">Select a client...</option>
|
<option value="">Select a client...</option>
|
||||||
@@ -157,7 +159,7 @@ async function stopTimer() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="selectedCategoryId"
|
v-model="selectedCategoryId"
|
||||||
class="select select-bordered w-full"
|
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"
|
:disabled="isRunning"
|
||||||
>
|
>
|
||||||
<option value="">Select a category...</option>
|
<option value="">Select a category...</option>
|
||||||
@@ -181,7 +183,7 @@ async function stopTimer() {
|
|||||||
v-model="description"
|
v-model="description"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
class="input input-bordered w-full"
|
class="input input-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"
|
:disabled="isRunning"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +199,10 @@ async function stopTimer() {
|
|||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'badge badge-lg cursor-pointer transition-all',
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline',
|
selectedTags.includes(tag.id)
|
||||||
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
|
: 'badge-outline hover:bg-base-300/50',
|
||||||
]"
|
]"
|
||||||
:disabled="isRunning"
|
:disabled="isRunning"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -211,18 +215,22 @@ async function stopTimer() {
|
|||||||
<!-- Timer and Action Row -->
|
<!-- Timer and Action Row -->
|
||||||
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
|
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
|
||||||
<div
|
<div
|
||||||
class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left grow"
|
class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left grow text-primary"
|
||||||
>
|
>
|
||||||
{{ formatTime(elapsedTime) }}
|
{{ formatTime(elapsedTime) }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="!isRunning"
|
v-if="!isRunning"
|
||||||
@click="startTimer"
|
@click="startTimer"
|
||||||
class="btn btn-primary btn-lg min-w-40"
|
class="btn btn-primary btn-lg min-w-40 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
|
||||||
>
|
>
|
||||||
▶️ Start Timer
|
▶️ Start Timer
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="stopTimer" class="btn btn-error btn-lg min-w-40">
|
<button
|
||||||
|
v-else
|
||||||
|
@click="stopTimer"
|
||||||
|
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
|
||||||
|
>
|
||||||
⏹️ Stop Timer
|
⏹️ Stop Timer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const timeEntries = sqliteTable(
|
|||||||
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
||||||
endTime: integer("end_time", { mode: "timestamp" }),
|
endTime: integer("end_time", { mode: "timestamp" }),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { db } from '../db';
|
|||||||
import { members, organizations } from '../db/schema';
|
import { members, organizations } from '../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
|
import Avatar from '../components/Avatar.astro';
|
||||||
|
import { ClientRouter } from "astro:transitions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -41,13 +43,14 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
<ClientRouter />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-linear-to-br from-base-100 via-base-200 to-base-100 h-screen flex flex-col overflow-hidden">
|
<body class="bg-base-100 h-screen flex flex-col overflow-hidden">
|
||||||
<div class="drawer lg:drawer-open flex-1 overflow-auto">
|
<div class="drawer lg:drawer-open flex-1 overflow-auto">
|
||||||
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||||
<div class="drawer-content flex flex-col h-full overflow-auto">
|
<div class="drawer-content flex flex-col h-full overflow-auto">
|
||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-300">
|
<div class="navbar bg-base-200/50 backdrop-blur-sm sticky top-0 z-50 lg:hidden border-b border-base-300/50">
|
||||||
<div class="flex-none lg:hidden">
|
<div class="flex-none lg:hidden">
|
||||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
||||||
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
||||||
@@ -66,10 +69,10 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-side z-50">
|
<div class="drawer-side z-50">
|
||||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
<ul class="menu bg-base-200 min-h-full w-80 p-4">
|
<ul class="menu bg-base-200/95 backdrop-blur-sm min-h-full w-80 p-4 border-r border-base-300/30">
|
||||||
<!-- Sidebar content here -->
|
<!-- Sidebar content here -->
|
||||||
<li class="mb-6">
|
<li class="mb-6">
|
||||||
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary">
|
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
|
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||||
Chronus
|
Chronus
|
||||||
</a>
|
</a>
|
||||||
@@ -80,7 +83,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<li class="mb-4">
|
<li class="mb-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<select
|
<select
|
||||||
class="select select-bordered w-full font-semibold"
|
class="select select-bordered w-full font-semibold bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary focus:outline-none focus:outline-offset-0 transition-all duration-200 hover:border-primary/40 focus:ring-3 focus:ring-primary/15 [&>option]:bg-base-300 [&>option]:text-base-content [&>option]:p-2"
|
||||||
id="team-switcher"
|
id="team-switcher"
|
||||||
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
||||||
>
|
>
|
||||||
@@ -108,57 +111,71 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
|
|
||||||
<div class="divider my-2"></div>
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
<li><a href="/dashboard">
|
<li><a href="/dashboard" class:list={[
|
||||||
|
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||||
|
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname === "/dashboard" }
|
||||||
|
]}>
|
||||||
<Icon name="heroicons:home" class="w-5 h-5" />
|
<Icon name="heroicons:home" class="w-5 h-5" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/dashboard/tracker">
|
<li><a href="/dashboard/tracker" class:list={[
|
||||||
|
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||||
|
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/tracker") }
|
||||||
|
]}>
|
||||||
<Icon name="heroicons:clock" class="w-5 h-5" />
|
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||||
Time Tracker
|
Time Tracker
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/dashboard/reports">
|
<li><a href="/dashboard/reports" class:list={[
|
||||||
|
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||||
|
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/reports") }
|
||||||
|
]}>
|
||||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||||
Reports
|
Reports
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/dashboard/clients">
|
<li><a href="/dashboard/clients" class:list={[
|
||||||
|
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||||
|
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/clients") }
|
||||||
|
]}>
|
||||||
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
||||||
Clients
|
Clients
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/dashboard/team">
|
<li><a href="/dashboard/team" class:list={[
|
||||||
|
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||||
|
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/team") }
|
||||||
|
]}>
|
||||||
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
||||||
Team
|
Team
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
{user.isSiteAdmin && (
|
{user.isSiteAdmin && (
|
||||||
<>
|
<>
|
||||||
<div class="divider"></div>
|
<div class="divider my-2"></div>
|
||||||
<li><a href="/admin" class="font-semibold">
|
<li><a href="/admin" class:list={[
|
||||||
|
"font-semibold hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||||
|
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/admin") }
|
||||||
|
]}>
|
||||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||||
Site Admin
|
Site Admin
|
||||||
</a></li>
|
</a></li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3">
|
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-300/30 hover:bg-base-300/60 rounded-lg p-3 transition-colors">
|
||||||
<div class="avatar placeholder">
|
<Avatar name={user.name} />
|
||||||
<div class="bg-linear-to-br from-primary via-secondary to-accent text-primary-content rounded-full w-10 ring ring-primary ring-offset-base-100 ring-offset-2">
|
|
||||||
<span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-semibold text-sm truncate">{user.name}</div>
|
<div class="font-semibold text-sm truncate">{user.name}</div>
|
||||||
<div class="text-xs text-base-content/60 truncate">{user.email}</div>
|
<div class="text-xs text-base-content/50 truncate">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-50" />
|
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-40" />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<form action="/api/auth/logout" method="POST">
|
<form action="/api/auth/logout" method="POST">
|
||||||
<button type="submit" class="w-full text-error hover:bg-error/10">
|
<button type="submit" class="w-full text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
|
||||||
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
|
import { ClientRouter } from "astro:transitions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,6 +19,7 @@ const { title } = Astro.props;
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
<ClientRouter />
|
||||||
</head>
|
</head>
|
||||||
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
|
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
|
import Avatar from '../../components/Avatar.astro';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { siteSettings, users } from '../../db/schema';
|
import { siteSettings, users } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -79,11 +80,7 @@ const allUsers = await db.select().from(users).all();
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="avatar placeholder">
|
<Avatar name={u.name} />
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
|
||||||
<span>{u.name.charAt(0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="font-bold">{u.name}</div>
|
<div class="font-bold">{u.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
195
src/pages/api/time-entries/manual.ts
Normal file
195
src/pages/api/time-entries/manual.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { db } from '../../../db';
|
||||||
|
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { description, clientId, categoryId, startTime, endTime, tags } = body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!clientId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Client is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Category is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startTime) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Start time is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endTime) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'End time is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(startTime);
|
||||||
|
const endDate = new Date(endTime);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Invalid date format' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'End time must be after start time' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's organization
|
||||||
|
const member = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.userId, locals.user.id))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No organization found' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify category belongs to organization
|
||||||
|
const category = await db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(categories.id, categoryId),
|
||||||
|
eq(categories.organizationId, member.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Invalid category' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client belongs to organization
|
||||||
|
const client = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.organizationId, member.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Invalid client' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nanoid();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Insert the manual time entry
|
||||||
|
await db.insert(timeEntries).values({
|
||||||
|
id,
|
||||||
|
userId: locals.user.id,
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
clientId,
|
||||||
|
categoryId,
|
||||||
|
startTime: startDate,
|
||||||
|
endTime: endDate,
|
||||||
|
description: description || null,
|
||||||
|
isManual: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert tags if provided
|
||||||
|
if (tags && Array.isArray(tags) && tags.length > 0) {
|
||||||
|
await db.insert(timeEntryTags).values(
|
||||||
|
tags.map((tagId: string) => ({
|
||||||
|
timeEntryId: id,
|
||||||
|
tagId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
id,
|
||||||
|
startTime: startDate.toISOString(),
|
||||||
|
endTime: endDate.toISOString(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating manual time entry:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to create time entry' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,51 +1,66 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
|
import {
|
||||||
import { eq, and, isNull } from 'drizzle-orm';
|
timeEntries,
|
||||||
import { nanoid } from 'nanoid';
|
members,
|
||||||
|
timeEntryTags,
|
||||||
|
categories,
|
||||||
|
} from "../../../db/schema";
|
||||||
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const description = body.description || '';
|
const description = body.description || "";
|
||||||
const clientId = body.clientId;
|
const clientId = body.clientId;
|
||||||
const categoryId = body.categoryId;
|
const categoryId = body.categoryId;
|
||||||
const tags = body.tags || [];
|
const tags = body.tags || [];
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return new Response('Client is required', { status: 400 });
|
return new Response("Client is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
return new Response('Category is required', { status: 400 });
|
return new Response("Category is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningEntry = await db.select().from(timeEntries).where(
|
const runningEntry = await db
|
||||||
and(
|
.select()
|
||||||
eq(timeEntries.userId, locals.user.id),
|
.from(timeEntries)
|
||||||
isNull(timeEntries.endTime)
|
.where(
|
||||||
|
and(eq(timeEntries.userId, locals.user.id), isNull(timeEntries.endTime)),
|
||||||
)
|
)
|
||||||
).get();
|
.get();
|
||||||
|
|
||||||
if (runningEntry) {
|
if (runningEntry) {
|
||||||
return new Response('Timer already running', { status: 400 });
|
return new Response("Timer already running", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = await db.select().from(members).where(eq(members.userId, locals.user.id)).limit(1).get();
|
const member = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.userId, locals.user.id))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return new Response('No organization found', { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await db.select().from(categories).where(
|
const category = await db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(categories.id, categoryId),
|
eq(categories.id, categoryId),
|
||||||
eq(categories.organizationId, member.organizationId)
|
eq(categories.organizationId, member.organizationId),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
).get();
|
.get();
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return new Response('Invalid category', { status: 400 });
|
return new Response("Invalid category", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = new Date();
|
const startTime = new Date();
|
||||||
@@ -59,6 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
categoryId,
|
categoryId,
|
||||||
startTime,
|
startTime,
|
||||||
description,
|
description,
|
||||||
|
isManual: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
@@ -66,7 +82,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
tags.map((tagId: string) => ({
|
tags.map((tagId: string) => ({
|
||||||
timeEntryId: id,
|
timeEntryId: id,
|
||||||
tagId,
|
tagId,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
|
import Avatar from '../../components/Avatar.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { members, users } from '../../db/schema';
|
import { members, users } from '../../db/schema';
|
||||||
@@ -70,11 +71,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="avatar placeholder">
|
<Avatar name={teamUser.name} />
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
|
||||||
<span>{teamUser.name.charAt(0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold">{teamUser.name}</div>
|
<div class="font-bold">{teamUser.name}</div>
|
||||||
{teamUser.id === user.id && (
|
{teamUser.id === user.id && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import Timer from '../../components/Timer.vue';
|
import Timer from '../../components/Timer.vue';
|
||||||
|
import ManualEntry from '../../components/ManualEntry.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
||||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||||
@@ -51,6 +52,7 @@ const offset = (page - 1) * pageSize;
|
|||||||
const filterClient = url.searchParams.get('client') || '';
|
const filterClient = url.searchParams.get('client') || '';
|
||||||
const filterCategory = url.searchParams.get('category') || '';
|
const filterCategory = url.searchParams.get('category') || '';
|
||||||
const filterStatus = url.searchParams.get('status') || '';
|
const filterStatus = url.searchParams.get('status') || '';
|
||||||
|
const filterType = url.searchParams.get('type') || '';
|
||||||
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
||||||
const searchTerm = url.searchParams.get('search') || '';
|
const searchTerm = url.searchParams.get('search') || '';
|
||||||
|
|
||||||
@@ -74,6 +76,12 @@ if (searchTerm) {
|
|||||||
conditions.push(like(timeEntries.description, `%${searchTerm}%`));
|
conditions.push(like(timeEntries.description, `%${searchTerm}%`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterType === 'manual') {
|
||||||
|
conditions.push(eq(timeEntries.isManual, true));
|
||||||
|
} else if (filterType === 'timed') {
|
||||||
|
conditions.push(eq(timeEntries.isManual, false));
|
||||||
|
}
|
||||||
|
|
||||||
const totalCount = await db.select({ count: sql<number>`count(*)` })
|
const totalCount = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
@@ -151,13 +159,17 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<DashboardLayout title="Time Tracker - Chronus">
|
<DashboardLayout title="Time Tracker - Chronus">
|
||||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
||||||
|
|
||||||
|
<!-- Tabs for Timer and Manual Entry -->
|
||||||
|
<div role="tablist" class="tabs tabs-lifted mb-6">
|
||||||
|
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
|
||||||
|
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||||
{allClients.length === 0 ? (
|
{allClients.length === 0 ? (
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning">
|
||||||
<span>You need to create a client before tracking time.</span>
|
<span>You need to create a client before tracking time.</span>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||||
</div>
|
</div>
|
||||||
) : allCategories.length === 0 ? (
|
) : allCategories.length === 0 ? (
|
||||||
<div class="alert alert-warning mb-6">
|
<div class="alert alert-warning">
|
||||||
<span>You need to create a category before tracking time.</span>
|
<span>You need to create a category before tracking time.</span>
|
||||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,11 +187,39 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
|
||||||
|
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||||
|
{allClients.length === 0 ? (
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<span>You need to create a client before adding time entries.</span>
|
||||||
|
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||||
|
</div>
|
||||||
|
) : allCategories.length === 0 ? (
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<span>You need to create a category before adding time entries.</span>
|
||||||
|
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ManualEntry
|
||||||
|
client:load
|
||||||
|
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||||
|
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
|
||||||
|
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allClients.length === 0 ? (
|
||||||
|
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
|
||||||
|
) : null}
|
||||||
|
|
||||||
<!-- Filters and Search -->
|
<!-- Filters and Search -->
|
||||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
<div class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200 mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Search</span>
|
<span class="label-text font-medium">Search</span>
|
||||||
@@ -188,7 +228,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
type="text"
|
type="text"
|
||||||
name="search"
|
name="search"
|
||||||
placeholder="Search descriptions..."
|
placeholder="Search descriptions..."
|
||||||
class="input input-bordered"
|
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +237,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Client</span>
|
<span class="label-text font-medium">Client</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
<select name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||||
<option value="">All Clients</option>
|
<option value="">All Clients</option>
|
||||||
{allClients.map(client => (
|
{allClients.map(client => (
|
||||||
<option value={client.id} selected={filterClient === client.id}>
|
<option value={client.id} selected={filterClient === client.id}>
|
||||||
@@ -211,7 +251,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Category</span>
|
<span class="label-text font-medium">Category</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
<select name="category" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{allCategories.map(category => (
|
{allCategories.map(category => (
|
||||||
<option value={category.id} selected={filterCategory === category.id}>
|
<option value={category.id} selected={filterCategory === category.id}>
|
||||||
@@ -225,18 +265,29 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Status</span>
|
<span class="label-text font-medium">Status</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="status" class="select select-bordered" onchange="this.form.submit()">
|
<select name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||||
<option value="" selected={filterStatus === ''}>All Entries</option>
|
<option value="" selected={filterStatus === ''}>All Entries</option>
|
||||||
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
||||||
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Entry Type</span>
|
||||||
|
</label>
|
||||||
|
<select name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||||
|
<option value="" selected={filterType === ''}>All Types</option>
|
||||||
|
<option value="timed" selected={filterType === 'timed'}>Timed</option>
|
||||||
|
<option value="manual" selected={filterType === 'manual'}>Manual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Sort By</span>
|
<span class="label-text font-medium">Sort By</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="sort" class="select select-bordered" onchange="this.form.submit()">
|
<select name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||||
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
|
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
|
||||||
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
||||||
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
||||||
@@ -245,8 +296,8 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="page" value="1" />
|
<input type="hidden" name="page" value="1" />
|
||||||
<div class="form-control md:col-span-2 lg:col-span-5">
|
<div class="form-control md:col-span-2 lg:col-span-6">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all">
|
||||||
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
@@ -255,24 +306,25 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-200/30 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
||||||
Time Entries ({totalCount?.count || 0} total)
|
Time Entries ({totalCount?.count || 0} total)
|
||||||
</h2>
|
</h2>
|
||||||
{(filterClient || filterCategory || filterStatus || searchTerm) && (
|
{(filterClient || filterCategory || filterStatus || filterType || searchTerm) && (
|
||||||
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost">
|
<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" />
|
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="bg-base-300/30">
|
||||||
|
<th>Type</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
@@ -285,17 +337,30 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map(({ entry, client, category, user: entryUser }) => (
|
{entries.map(({ entry, client, category, user: entryUser }) => (
|
||||||
<tr>
|
<tr class="hover:bg-base-300/20 transition-colors">
|
||||||
<td>{client?.name || 'Unknown'}</td>
|
<td>
|
||||||
|
{entry.isManual ? (
|
||||||
|
<span class="badge badge-info badge-sm gap-1 shadow-sm" title="Manual Entry">
|
||||||
|
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||||
|
Manual
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span class="badge badge-success badge-sm gap-1 shadow-sm" title="Timed Entry">
|
||||||
|
<Icon name="heroicons:clock" class="w-3 h-3" />
|
||||||
|
Timed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td class="font-medium">{client?.name || 'Unknown'}</td>
|
||||||
<td>
|
<td>
|
||||||
{category ? (
|
{category ? (
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${category.color}`}></span>
|
<span class="w-3 h-3 rounded-full shadow-sm" style={`background-color: ${category.color}`}></span>
|
||||||
<span>{category.name}</span>
|
<span>{category.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>{entry.description || '-'}</td>
|
<td class="text-base-content/80">{entry.description || '-'}</td>
|
||||||
<td>{entryUser?.name || 'Unknown'}</td>
|
<td>{entryUser?.name || 'Unknown'}</td>
|
||||||
<td class="whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
{entry.startTime.toLocaleDateString()}<br/>
|
{entry.startTime.toLocaleDateString()}<br/>
|
||||||
@@ -312,15 +377,15 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span class="badge badge-success">Running</span>
|
<span class="badge badge-success shadow-sm">Running</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
<td class="font-mono font-semibold text-primary">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-ghost btn-sm text-error"
|
class="btn btn-ghost btn-sm text-error hover:bg-error/10 transition-colors"
|
||||||
onclick="return confirm('Are you sure you want to delete this entry?')"
|
onclick="return confirm('Are you sure you want to delete this entry?')"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||||
@@ -337,8 +402,8 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div class="flex justify-center items-center gap-2 mt-6">
|
<div class="flex justify-center items-center gap-2 mt-6">
|
||||||
<a
|
<a
|
||||||
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||||
class={`btn btn-sm ${page === 1 ? 'btn-disabled' : ''}`}
|
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" />
|
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
|
||||||
Previous
|
Previous
|
||||||
@@ -347,8 +412,8 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{paginationPages.map(pageNum => (
|
{paginationPages.map(pageNum => (
|
||||||
<a
|
<a
|
||||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||||
class={`btn btn-sm ${page === pageNum ? 'btn-active' : ''}`}
|
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</a>
|
</a>
|
||||||
@@ -356,8 +421,8 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||||
class={`btn btn-sm ${page === totalPages ? 'btn-disabled' : ''}`}
|
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
||||||
|
|
||||||
export default createCatppuccinPlugin(
|
export default createCatppuccinPlugin(
|
||||||
"mocha",
|
"macchiato",
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user