Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
7922af92e2 move Client visibleByEmployee logic to model scope 2025-10-21 11:53:08 +02:00
Alexander Groß
d1d2aedbae Show clients that are assigned to the employee, closes #893 2025-10-17 09:52:15 +02:00
26 changed files with 110 additions and 446 deletions

View File

@@ -22,27 +22,13 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens')) ->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes(); ->everyTenMinutes();
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) { $schedule->command('self-host:check-for-update')
// Convert string to a stable integer for seeding ->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
/** @var int $seed Take the first 8 hex chars → 32-bit int */ ->twiceDaily();
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
$seed = abs($seed); // Ensure it's positive
mt_srand($seed);
$firstHour = mt_rand(0, 23);
$secondHour = ($firstHour + 12) % 24;
$minuteOffset = mt_rand(0, 59);
mt_srand(null); // Reset the random number generator
if (config('scheduling.tasks.self_hosting_check_for_update')) { $schedule->command('self-host:telemetry')
$schedule->command('self-host:check-for-update') ->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDailyAt($firstHour, $secondHour, $minuteOffset); ->twiceDaily();
}
if (config('scheduling.tasks.self_hosting_telemetry')) {
$schedule->command('self-host:telemetry')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
}
$schedule->command('self-host:database-consistency') $schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency')) ->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))

View File

@@ -42,7 +42,7 @@ class HandleInertiaRequests extends Middleware
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing'); $hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services'); $hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */ /** @var BillingContract $billing */
$billing = app(BillingContract::class); $billing = app(BillingContract::class);

View File

@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'description' => [ 'description' => [
'nullable', 'nullable',
'string', 'string',
'max:5000', 'max:500',
], ],
// List of tag IDs // List of tag IDs
'tags' => [ 'tags' => [

View File

@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.description' => [ 'changes.description' => [
'nullable', 'nullable',
'string', 'string',
'max:5000', 'max:500',
], ],
// List of tag IDs // List of tag IDs
'changes.tags' => [ 'changes.tags' => [

View File

@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'description' => [ 'description' => [
'nullable', 'nullable',
'string', 'string',
'max:5000', 'max:500',
], ],
// List of tag IDs // List of tag IDs
'tags' => [ 'tags' => [

View File

@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->project_id = $projectId; $timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId; $timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id; $timeEntry->organization_id = $this->organization->id;
if (strlen($record['Description']) > 5000) { if (strlen($record['Description']) > 500) {
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $record['Description']; $timeEntry->description = $record['Description'];

View File

@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
$timeEntry->project_id = $projectId; $timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId; $timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id; $timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 5000) { if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long'); throw new ImportException('Time entry note is too long');
} }
$timeEntry->description = $record['Notes']; $timeEntry->description = $record['Notes'];

View File

@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
$timeEntry->project_id = $projectId; $timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId; $timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id; $timeEntry->organization_id = $this->organization->id;
if (strlen($timeEntryRow['description']) > 5000) { if (strlen($timeEntryRow['description']) > 500) {
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $timeEntryRow['description']; $timeEntry->description = $timeEntryRow['description'];

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('description', 5000)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('description', 500)->change();
});
}
};

View File

@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
CREATE TABLE public.time_entries ( CREATE TABLE public.time_entries (
id uuid NOT NULL, id uuid NOT NULL,
description character varying(5000) NOT NULL, description character varying(500) NOT NULL,
start timestamp(0) without time zone NOT NULL, start timestamp(0) without time zone NOT NULL,
"end" timestamp(0) without time zone, "end" timestamp(0) without time zone,
billable_rate integer, billable_rate integer,

View File

@@ -9,10 +9,7 @@ async function goToOrganizationSettings(page) {
async function createTimeEntry(page, duration: string) { async function createTimeEntry(page, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page.getByTestId('time_entry_description').fill('Test time entry'); await page.getByTestId('time_entry_description').fill('Test time entry');

View File

@@ -26,10 +26,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
// Then create the time entry // Then create the time entry
await goToTimeOverview(page); await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page await page
@@ -55,10 +52,7 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) { async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await goToTimeOverview(page); await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page await page
@@ -87,10 +81,7 @@ async function createTimeEntryWithBillableStatus(
duration: string duration: string
) { ) {
await goToTimeOverview(page); await goToTimeOverview(page);
await page.getByRole('button', { name: 'Manual time entry' }).click();
// Open the dropdown menu and click "Manual time entry"
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
// Fill in the time entry details // Fill in the time entry details
await page await page

View File

@@ -10,8 +10,7 @@ defineProps<{
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator"> <div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
<div class="col-span-2"> <div class="col-span-2">
<div class="flex justify-between"> <div class="flex justify-between">
<p <p class="font-semibold text-sm text-text-primary">
class="font-semibold text-sm min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
{{ name }} {{ name }}
</p> </p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end"> <div v-if="working" class="flex space-x-1.5 items-center justify-end">

View File

@@ -16,25 +16,12 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue'; import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
import type { import type { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
CreateClientBody,
CreateProjectBody,
CreateTimeEntryBody,
Project,
Tag,
} from '@/packages/api/src';
import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue'; import TimeTrackerRunningInDifferentOrganizationOverlay from '@/packages/ui/src/TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import { ref } from 'vue';
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { useNotificationsStore } from '@/utils/notification';
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -60,8 +47,6 @@ const emit = defineEmits<{
change: []; change: [];
}>(); }>();
const showManualTimeEntryModal = ref(false);
watch(isActive, () => { watch(isActive, () => {
if (isActive.value) { if (isActive.value) {
startLiveTimer(); startLiveTimer();
@@ -108,70 +93,14 @@ function switchToTimeEntryOrganization() {
switchOrganization(currentTimeEntry.value.organization_id); switchOrganization(currentTimeEntry.value.organization_id);
} }
} }
async function createTag(tag: string): Promise<Tag | undefined> { async function createTag(tag: string) {
return await useTagsStore().createTag(tag); return await useTagsStore().createTag(tag);
} }
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
await useTimeEntriesStore().createTimeEntry(timeEntry);
showManualTimeEntryModal.value = false;
}
async function createTimeEntryFromCurrentEntry() {
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
currentTimeEntryStore.$reset();
}
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
const deleteTimeEntryMutation = useMutation({
mutationFn: async (timeEntryId: string) => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) {
throw new Error('No organization selected');
}
return await api.deleteTimeEntry(undefined, {
params: {
organization: organizationId,
timeEntry: timeEntryId,
},
});
},
onSuccess: async () => {
await currentTimeEntryStore.fetchCurrentTimeEntry();
await useTimeEntriesStore().fetchTimeEntries();
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
},
});
async function discardCurrentTimeEntry() {
if (currentTimeEntry.value.id) {
await handleApiRequestNotifications(
() => deleteTimeEntryMutation.mutateAsync(currentTimeEntry.value.id),
'Time entry discarded successfully',
'Failed to discard time entry'
);
}
}
const { tags } = storeToRefs(useTagsStore()); const { tags } = storeToRefs(useTagsStore());
</script> </script>
<template> <template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle> <CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="relative"> <div class="relative">
<TimeTrackerRunningInDifferentOrganizationOverlay <TimeTrackerRunningInDifferentOrganizationOverlay
@@ -180,35 +109,24 @@ const { tags } = storeToRefs(useTagsStore());
switchToTimeEntryOrganization switchToTimeEntryOrganization
"></TimeTrackerRunningInDifferentOrganizationOverlay> "></TimeTrackerRunningInDifferentOrganizationOverlay>
<div class="flex w-full items-center gap-2"> <TimeTrackerControls
<div class="flex w-full items-center gap-2"> v-model:current-time-entry="currentTimeEntry"
<div class="flex-1"> v-model:live-timer="now"
<TimeTrackerControls :create-project
v-model:current-time-entry="currentTimeEntry" :enable-estimated-time="isAllowedToPerformPremiumAction()"
v-model:live-timer="now" :can-create-project="canCreateProjects()"
:create-project :create-client
:enable-estimated-time="isAllowedToPerformPremiumAction()" :clients
:can-create-project="canCreateProjects()" :tags
:create-client :tasks
:clients :projects
:tags :create-tag
:tasks :is-active
:projects :currency="getOrganizationCurrencyString()"
:create-tag @start-live-timer="startLiveTimer"
:is-active @stop-live-timer="stopLiveTimer"
:currency="getOrganizationCurrencyString()" @start-timer="setActiveState(true)"
@start-live-timer="startLiveTimer" @stop-timer="setActiveState(false)"
@stop-live-timer="stopLiveTimer" @update-time-entry="updateTimeEntry"></TimeTrackerControls>
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"
@manual-entry="showManualTimeEntryModal = true"
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -9,7 +9,7 @@ import {
type Project, type Project,
type TimeEntryResponse, type TimeEntryResponse,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time'; import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { TimeEntryCalendar } from '@/packages/ui/src'; import { TimeEntryCalendar } from '@/packages/ui/src';
@@ -73,7 +73,6 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
queries: { queries: {
start: expandedDateRange.value.start!, start: expandedDateRange.value.start!,
end: expandedDateRange.value.end!, end: expandedDateRange.value.end!,
member_id: getCurrentMembershipId(),
}, },
}), }),
}); });

View File

@@ -15,6 +15,8 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import { useElementVisibility } from '@vueuse/core'; import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid'; import { ClockIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { useTasksStore } from '@/utils/useTasks'; import { useTasksStore } from '@/utils/useTasks';
@@ -22,6 +24,7 @@ import { useProjectsStore } from '@/utils/useProjects';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue'; import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
import { useTagsStore } from '@/utils/useTags'; import { useTagsStore } from '@/utils/useTags';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src'; import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
@@ -70,6 +73,7 @@ onMounted(async () => {
await timeEntriesStore.fetchTimeEntries(); await timeEntriesStore.fetchTimeEntries();
}); });
const showManualTimeEntryModal = ref(false);
const projectStore = useProjectsStore(); const projectStore = useProjectsStore();
const { projects } = storeToRefs(projectStore); const { projects } = storeToRefs(projectStore);
const taskStore = useTasksStore(); const taskStore = useTasksStore();
@@ -101,9 +105,33 @@ function deleteSelected() {
</script> </script>
<template> <template>
<TimeEntryCreateModal
v-model:show="showManualTimeEntryModal"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:create-time-entry="createTimeEntry"
:projects
:tasks
:tags
:clients></TimeEntryCreateModal>
<AppLayout title="Dashboard" data-testid="time_view"> <AppLayout title="Dashboard" data-testid="time_view">
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6"> <MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimeTracker></TimeTracker> <div
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
<div class="flex-1">
<TimeTracker></TimeTracker>
</div>
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
<SecondaryButton
class="w-full text-center flex justify-center"
:icon="PlusIcon"
@click="showManualTimeEntryModal = true"
>Manual time entry
</SecondaryButton>
</div>
</div>
</MainContainer> </MainContainer>
<TimeEntryMassActionRow <TimeEntryMassActionRow
:selected-time-entries="selectedTimeEntries" :selected-time-entries="selectedTimeEntries"

View File

@@ -93,7 +93,6 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
data-testid="time_picker_input" data-testid="time_picker_input"
type="text" type="text"
@blur="updateTime" @blur="updateTime"
@keydown.enter.prevent="updateTime"
@focus="($event.target as HTMLInputElement).select()" @focus="($event.target as HTMLInputElement).select()"
@mouseup="($event.target as HTMLInputElement).select()" @mouseup="($event.target as HTMLInputElement).select()"
@click="($event.target as HTMLInputElement).select()" @click="($event.target as HTMLInputElement).select()"

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, nextTick, ref, watch } from 'vue'; import { defineProps, nextTick, ref, watch } from 'vue';
import { useFocusWithin } from '@vueuse/core';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue'; import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue'; import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import { Button } from '@/Components/ui/button';
const props = defineProps<{ const props = defineProps<{
start: string; start: string;
@@ -17,42 +17,31 @@ const emit = defineEmits(['changed', 'close']);
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format()); const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null); const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
const showEndTimePicker = ref(false);
watch(props, () => { watch(props, () => {
tempStart.value = getLocalizedDayJs(props.start).format(); tempStart.value = getLocalizedDayJs(props.start).format();
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null; tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
showEndTimePicker.value = false;
}); });
function updateTimeEntry() { function updateTimeEntry() {
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format(); const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null; const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
if (tempStartUtc !== props.start || tempEndUtc !== props.end) { if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
emit( emit(
'changed', 'changed',
getDayJsInstance()(tempStart.value).utc().format(), getDayJsInstance()(tempStart.value).utc().format(),
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null getDayJsInstance()(tempEnd.value).utc().format()
); );
} }
} }
function setEndTime() {
showEndTimePicker.value = true;
tempEnd.value = getDayJsInstance()().format();
}
function confirmEndTime() {
// wait for the v-model for the end time to update
nextTick(() => {
updateTimeEntry();
showEndTimePicker.value = false;
emit('close');
});
}
const dropdownContent = ref(); const dropdownContent = ref();
const { focused } = useFocusWithin(dropdownContent);
watch(focused, (newValue, oldValue) => {
if (oldValue === true && newValue === false) {
updateTimeEntry();
}
});
</script> </script>
<template> <template>
@@ -78,7 +67,7 @@ const dropdownContent = ref();
</div> </div>
<div class="px-2"> <div class="px-2">
<div class="font-semibold text-text-primary text-sm pb-2">End</div> <div class="font-semibold text-text-primary text-sm pb-2">End</div>
<div v-if="end !== null && tempEnd !== null" class="space-y-2"> <div v-if="tempEnd !== null" class="space-y-2">
<TimePickerSimple <TimePickerSimple
v-model="tempEnd" v-model="tempEnd"
data-testid="time_entry_range_end" data-testid="time_entry_range_end"
@@ -88,22 +77,6 @@ const dropdownContent = ref();
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5" class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@changed="updateTimeEntry"></DatePicker> @changed="updateTimeEntry"></DatePicker>
</div> </div>
<div v-else-if="end === null && !showEndTimePicker">
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
</div>
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
<TimePickerSimple
v-model="tempEnd"
data-testid="time_entry_range_end"
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
<DatePicker
v-model="tempEnd"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@keydown.enter.prevent="confirmEndTime"></DatePicker>
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
Confirm
</Button>
</div>
<div v-else class="text-text-secondary">-- : --</div> <div v-else class="text-text-secondary">-- : --</div>
<div tabindex="0" @focusin="emit('close')"></div> <div tabindex="0" @focusin="emit('close')"></div>
</div> </div>

View File

@@ -68,6 +68,19 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(
() => editableTimeEntry.value?.project_id,
(value) => {
if (value && editableTimeEntry.value) {
// check if project is billable by default and set billable accordingly
const project = props.projects.find((p) => p.id === value);
if (project) {
editableTimeEntry.value.billable = project.is_billable;
}
}
}
);
const localStart = computed({ const localStart = computed({
get: () => get: () =>
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '', editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',

View File

@@ -29,7 +29,7 @@ const open = ref(false);
function updateTimerAndStartLiveTimerUpdate() { function updateTimerAndStartLiveTimerUpdate() {
const defaultUnit = const defaultUnit =
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes'; organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit); const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
if (seconds && seconds > 0) { if (seconds && seconds > 0) {
let newEndDate = props.end; let newEndDate = props.end;
let newStartDate = props.start; let newStartDate = props.start;

View File

@@ -49,7 +49,6 @@ const emit = defineEmits<{
updateTimeEntry: []; updateTimeEntry: [];
startLiveTimer: []; startLiveTimer: [];
stopLiveTimer: []; stopLiveTimer: [];
createTimeEntry: [];
}>(); }>();
function updateProject() { function updateProject() {
@@ -281,7 +280,6 @@ useSelectEvents(
@stop-live-timer="emit('stopLiveTimer')" @stop-live-timer="emit('stopLiveTimer')"
@update-timer="emit('updateTimeEntry')" @update-timer="emit('updateTimeEntry')"
@start-timer="emit('startTimer')" @start-timer="emit('startTimer')"
@create-time-entry="emit('createTimeEntry')"
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector> @keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
</div> </div>
</div> </div>

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
const props = defineProps<{
hasActiveTimer: boolean;
}>();
const emit = defineEmits<{
manualEntry: [];
discard: [];
}>();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="focus-visible:outline-none focus-visible:bg-card-background rounded-full focus-visible:ring-2 focus-visible:ring-ring hover:bg-card-background hover:opacity-100 opacity-20 transition-opacity text-text-secondary"
aria-label="Time entry actions">
<svg
class="h-8 w-8 p-1 rounded-full"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[150px]" align="end">
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="emit('manualEntry')">
<PlusIcon class="w-5" />
<span>Manual time entry</span>
</DropdownMenuItem>
<DropdownMenuItem
v-if="props.hasActiveTimer"
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
@click="emit('discard')">
<XMarkIcon class="w-5" />
<span>Discard</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<style scoped></style>

View File

@@ -16,7 +16,6 @@ const emit = defineEmits<{
stopLiveTimer: []; stopLiveTimer: [];
updateTimer: []; updateTimer: [];
startTimer: []; startTimer: [];
createTimeEntry: [];
}>(); }>();
const open = ref(false); const open = ref(false);
@@ -56,7 +55,7 @@ const currentTime = computed({
}); });
function updateTimerAndStartLiveTimerUpdate() { function updateTimerAndStartLiveTimerUpdate() {
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes'); const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
if (seconds && seconds > 0) { if (seconds && seconds > 0) {
const newStartDate = dayjs().subtract(seconds, 's'); const newStartDate = dayjs().subtract(seconds, 's');
@@ -74,16 +73,12 @@ function updateTimerAndStartLiveTimerUpdate() {
const temporaryCustomTimerEntry = ref<string>(''); const temporaryCustomTimerEntry = ref<string>('');
async function updateTimeRange(newStart: string, newEnd: string | null) { async function updateTimeRange(newStart: string) {
// prohibit updates in the future // prohibit updates in the future
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) { if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
currentTimeEntry.value.start = newStart; currentTimeEntry.value.start = newStart;
currentTimeEntry.value.end = newEnd;
if (currentTimeEntry.value.id) { if (currentTimeEntry.value.id) {
emit('updateTimer'); emit('updateTimer');
} else if (newEnd !== null) {
// If there's no ID but we have both start and end, create a new time entry
emit('createTimeEntry');
} else { } else {
emit('startTimer'); emit('startTimer');
} }
@@ -96,21 +91,11 @@ const startTime = computed(() => {
} }
return dayjs().utc().format(); return dayjs().utc().format();
}); });
const endTime = computed(() => {
if (currentTimeEntry.value.end && currentTimeEntry.value.end !== '') {
return currentTimeEntry.value.end;
}
return null;
});
const inputField = ref<HTMLInputElement | null>(null); const inputField = ref<HTMLInputElement | null>(null);
const timeRangeSelector = ref<HTMLElement | null>(null); const timeRangeSelector = ref<HTMLElement | null>(null);
function openModalOnTab(e: FocusEvent) { function openModalOnTab(e: FocusEvent) {
pauseLiveTimerUpdate(e);
// check if the source is inside the dropdown // check if the source is inside the dropdown
const source = e.relatedTarget as HTMLElement; const source = e.relatedTarget as HTMLElement;
if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) { if (source && window.document.body.querySelector<HTMLElement>('#app')?.contains(source)) {
@@ -118,12 +103,6 @@ function openModalOnTab(e: FocusEvent) {
} }
} }
function openModalOnClick(e: MouseEvent) {
pauseLiveTimerUpdate(e);
open.value = true;
}
function focusNextElement(e: KeyboardEvent) { function focusNextElement(e: KeyboardEvent) {
if (open.value) { if (open.value) {
e.preventDefault(); e.preventDefault();
@@ -156,8 +135,8 @@ function closeAndFocusInput() {
data-testid="time_entry_time" data-testid="time_entry_time"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition" class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
type="text" type="text"
@focus="pauseLiveTimerUpdate"
@focusin="openModalOnTab" @focusin="openModalOnTab"
@click="openModalOnClick"
@keydown.exact.tab="focusNextElement" @keydown.exact.tab="focusNextElement"
@keydown.exact.shift.tab="open = false" @keydown.exact.shift.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate" @blur="updateTimerAndStartLiveTimerUpdate"
@@ -167,7 +146,7 @@ function closeAndFocusInput() {
<div ref="timeRangeSelector"> <div ref="timeRangeSelector">
<TimeRangeSelector <TimeRangeSelector
:start="startTime" :start="startTime"
:end="endTime" :end="null"
@changed="updateTimeRange" @changed="updateTimeRange"
@close="closeAndFocusInput"> @close="closeAndFocusInput">
</TimeRangeSelector> </TimeRangeSelector>

View File

@@ -208,30 +208,22 @@ export function formatStartEnd(
export function parseTimeInput( export function parseTimeInput(
input: string, input: string,
defaultUnit: TimeInputUnit = 'minutes' defaultUnit: TimeInputUnit = 'minutes'
): number | null { ): {
seconds: number | null;
isHHMM: boolean;
} {
// Check if input is a decimal number (hours) // Check if input is a decimal number (hours)
const decimalRegex = /^-?\d+[.,]\d+$/; const decimalRegex = /^-?\d+[.,]\d+$/;
if (decimalRegex.test(input)) { if (decimalRegex.test(input)) {
const hours = parseFloat(input.replace(',', '.')); const hours = parseFloat(input.replace(',', '.'));
return Math.round(hours * 3600); return { seconds: Math.round(hours * 3600), isHHMM: false };
} }
// Check if input is just a number (minutes or hours based on defaultUnit) // Check if input is just a number (minutes or hours based on defaultUnit)
if (/^-?\d+$/.test(input)) { if (/^-?\d+$/.test(input)) {
const value = parseInt(input); const value = parseInt(input);
return defaultUnit === 'minutes' ? value * 60 : value * 3600; const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
} return { seconds, isHHMM: false };
// Check if input is in HH:MM:SS format
const HHMMSStimeRegex = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/;
if (HHMMSStimeRegex.test(input)) {
const match = input.match(HHMMSStimeRegex);
if (match) {
const hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
const seconds = parseInt(match[3]);
return hours * 3600 + minutes * 60 + seconds;
}
} }
// Check if input is in HH:MM format // Check if input is in HH:MM format
@@ -241,15 +233,15 @@ export function parseTimeInput(
if (match) { if (match) {
const hours = parseInt(match[1]); const hours = parseInt(match[1]);
const minutes = parseInt(match[2]); const minutes = parseInt(match[2]);
return (hours * 60 + minutes) * 60; return { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
} }
} }
// Try to parse natural language like "1h 30m" // Try to parse natural language like "1h 30m"
const parsedDuration = parse(input, 's'); const parsedDuration = parse(input, 's');
if (parsedDuration && parsedDuration > 0) { if (parsedDuration && parsedDuration > 0) {
return parsedDuration; return { seconds: parsedDuration, isHHMM: false };
} }
return null; return { seconds: null, isHHMM: false };
} }

View File

@@ -162,7 +162,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
task_id: currentTimeEntry.value.task_id, task_id: currentTimeEntry.value.task_id,
start: currentTimeEntry.value.start, start: currentTimeEntry.value.start,
billable: currentTimeEntry.value.billable, billable: currentTimeEntry.value.billable,
end: currentTimeEntry.value.end, end: null,
tags: currentTimeEntry.value.tags, tags: currentTimeEntry.value.tags,
}, },
{ {
@@ -175,12 +175,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
'Time entry updated!' 'Time entry updated!'
); );
if (response?.data) { if (response?.data) {
if (response.data.end === null) { currentTimeEntry.value = response.data;
currentTimeEntry.value = response.data;
} else {
$reset();
stopLiveTimer();
}
} }
} else { } else {
throw new Error( throw new Error(
@@ -220,6 +215,5 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer, stopLiveTimer,
now, now,
setActiveState, setActiveState,
$reset,
}; };
}); });

View File

@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console;
use App\Console\Kernel;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(Kernel::class)]
class KernelTest extends TestCase
{
public function test_self_host_commands_schedule_time_is_consistent_with_app_key(): void
{
// Arrange
config([
'app.key' => 'base64:cOXN4GLMXYjcdG0fKosnFogofXw1pNoXkLAViRH+a5Y=',
]);
// Act
$schedule1 = app()->make(Kernel::class)->resolveConsoleSchedule();
$firstRunEvents = collect($schedule1->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
str_contains($event->command, 'self-host:telemetry')
);
$schedule2 = app()->make(Kernel::class)->resolveConsoleSchedule();
$secondRunEvents = collect($schedule2->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
str_contains($event->command, 'self-host:telemetry')
);
config([
'app.key' => 'base64:eP58hkQ8l3guqf8wvWJR7pB0weVQtnpjMdYpaVwX4Jw=',
]);
$schedule3 = app()->make(Kernel::class)->resolveConsoleSchedule();
$thirdRunEvents = collect($schedule3->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update') ||
str_contains($event->command, 'self-host:telemetry')
);
// Assert
$this->assertCount(2, $firstRunEvents);
$this->assertCount(2, $secondRunEvents);
$this->assertCount(2, $thirdRunEvents);
foreach ($firstRunEvents as $index => $event) {
$this->assertSame('52 9,21 * * *', $firstRunEvents[$index]->expression);
$this->assertSame('52 9,21 * * *', $secondRunEvents[$index]->expression);
$this->assertSame('48 13,1 * * *', $thirdRunEvents[$index]->expression);
}
}
public function test_self_hosting_telemetry_can_be_activated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_telemetry' => true,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')
);
// Assert
$this->assertCount(1, $events);
}
public function test_self_hosting_telemetry_can_be_deactivated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_telemetry' => false,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:telemetry')
);
// Assert
$this->assertCount(0, $events);
}
public function test_self_hosting_check_for_update_can_be_activated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_check_for_update' => true,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')
);
// Assert
$this->assertCount(1, $events);
}
public function test_self_hosting_check_for_update_can_be_deactivated(): void
{
// Arrange
config([
'scheduling.tasks.self_hosting_check_for_update' => false,
]);
// Act
$schedule = app()->make(Kernel::class)->resolveConsoleSchedule();
$events = collect($schedule->events())->filter(fn ($event) => str_contains($event->command, 'self-host:check-for-update')
);
// Assert
$this->assertCount(0, $events);
}
}