240 lines
7.2 KiB
Vue
240 lines
7.2 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted } from "vue";
|
||
|
||
const props = defineProps<{
|
||
initialRunningEntry: {
|
||
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 }[];
|
||
}>();
|
||
|
||
const isRunning = ref(false);
|
||
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;
|
||
|
||
function formatTime(ms: number) {
|
||
const totalSeconds = Math.floor(ms / 1000);
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
|
||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||
|
||
// Calculate rounded version
|
||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||
const roundedHours = Math.floor(totalMinutes / 60);
|
||
const roundedMinutes = totalMinutes % 60;
|
||
|
||
let roundedStr = "";
|
||
if (roundedHours > 0) {
|
||
roundedStr =
|
||
roundedMinutes > 0
|
||
? `${roundedHours}h ${roundedMinutes}m`
|
||
: `${roundedHours}h`;
|
||
} else {
|
||
roundedStr = `${roundedMinutes}m`;
|
||
}
|
||
|
||
return `${timeStr} (${roundedStr})`;
|
||
}
|
||
|
||
function toggleTag(tagId: string) {
|
||
const index = selectedTags.value.indexOf(tagId);
|
||
if (index > -1) {
|
||
selectedTags.value.splice(index, 1);
|
||
} else {
|
||
selectedTags.value.push(tagId);
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (props.initialRunningEntry) {
|
||
isRunning.value = true;
|
||
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!;
|
||
}, 1000);
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (interval) clearInterval(interval);
|
||
});
|
||
|
||
async function startTimer() {
|
||
if (!selectedClientId.value) {
|
||
alert("Please select a client");
|
||
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,
|
||
}),
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
startTime.value = new Date(data.startTime).getTime();
|
||
isRunning.value = true;
|
||
interval = setInterval(() => {
|
||
elapsedTime.value = Date.now() - startTime.value!;
|
||
}, 1000);
|
||
}
|
||
}
|
||
|
||
async function stopTimer() {
|
||
const res = await fetch("/api/time-entries/stop", {
|
||
method: "POST",
|
||
});
|
||
|
||
if (res.ok) {
|
||
isRunning.value = false;
|
||
if (interval) clearInterval(interval);
|
||
elapsedTime.value = 0;
|
||
startTime.value = null;
|
||
description.value = "";
|
||
selectedClientId.value = "";
|
||
selectedCategoryId.value = "";
|
||
selectedTags.value = [];
|
||
window.location.reload();
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<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">
|
||
<!-- 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">
|
||
<span class="label-text font-medium">Client</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="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">
|
||
<span class="label-text font-medium">Category</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="isRunning"
|
||
>
|
||
<option value="">Select a category...</option>
|
||
<option
|
||
v-for="category in categories"
|
||
:key="category.id"
|
||
:value="category.id"
|
||
>
|
||
{{ category.name }}
|
||
</option>
|
||
</select>
|
||
</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 are you working 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="isRunning"
|
||
/>
|
||
</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="isRunning"
|
||
type="button"
|
||
>
|
||
{{ tag.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Timer and Action Row -->
|
||
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
|
||
<div
|
||
class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left grow text-primary"
|
||
>
|
||
{{ formatTime(elapsedTime) }}
|
||
</div>
|
||
<button
|
||
v-if="!isRunning"
|
||
@click="startTimer"
|
||
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
|
||
</button>
|
||
<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
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|