mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
Compare commits
11 Commits
pullreques
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3417b60585 | ||
|
|
0f21fabd37 | ||
|
|
df00200464 | ||
|
|
3b41de7135 | ||
|
|
9fe0ea5a0f | ||
|
|
f8f708a664 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 |
@@ -22,13 +22,27 @@ 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();
|
||||||
|
|
||||||
$schedule->command('self-host:check-for-update')
|
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
// Convert string to a stable integer for seeding
|
||||||
->twiceDaily();
|
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
||||||
|
$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
|
||||||
|
|
||||||
$schedule->command('self-host:telemetry')
|
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
||||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
$schedule->command('self-host:check-for-update')
|
||||||
->twiceDaily();
|
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:500',
|
'max:5000',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
|||||||
'changes.description' => [
|
'changes.description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:500',
|
'max:5000',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'changes.tags' => [
|
'changes.tags' => [
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
|||||||
'description' => [
|
'description' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
'max:500',
|
'max:5000',
|
||||||
],
|
],
|
||||||
// List of tag IDs
|
// List of tag IDs
|
||||||
'tags' => [
|
'tags' => [
|
||||||
|
|||||||
@@ -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']) > 500) {
|
if (strlen($record['Description']) > 5000) {
|
||||||
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'];
|
||||||
|
|||||||
@@ -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']) > 500) {
|
if (strlen($record['Notes']) > 5000) {
|
||||||
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'];
|
||||||
|
|||||||
@@ -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']) > 500) {
|
if (strlen($timeEntryRow['description']) > 5000) {
|
||||||
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'];
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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(500) NOT NULL,
|
description character varying(5000) 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,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ 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');
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ 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
|
||||||
@@ -52,7 +55,10 @@ 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
|
||||||
@@ -81,7 +87,10 @@ 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
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ 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 class="font-semibold text-sm text-text-primary">
|
<p
|
||||||
|
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">
|
||||||
|
|||||||
@@ -16,12 +16,25 @@ 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 { CreateClientBody, CreateProjectBody, Project } from '@/packages/api/src';
|
import type {
|
||||||
|
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: {
|
||||||
@@ -47,6 +60,8 @@ const emit = defineEmits<{
|
|||||||
change: [];
|
change: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showManualTimeEntryModal = ref(false);
|
||||||
|
|
||||||
watch(isActive, () => {
|
watch(isActive, () => {
|
||||||
if (isActive.value) {
|
if (isActive.value) {
|
||||||
startLiveTimer();
|
startLiveTimer();
|
||||||
@@ -93,14 +108,70 @@ function switchToTimeEntryOrganization() {
|
|||||||
switchOrganization(currentTimeEntry.value.organization_id);
|
switchOrganization(currentTimeEntry.value.organization_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function createTag(tag: string) {
|
async function createTag(tag: string): Promise<Tag | undefined> {
|
||||||
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
|
||||||
@@ -109,24 +180,35 @@ const { tags } = storeToRefs(useTagsStore());
|
|||||||
switchToTimeEntryOrganization
|
switchToTimeEntryOrganization
|
||||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||||
|
|
||||||
<TimeTrackerControls
|
<div class="flex w-full items-center gap-2">
|
||||||
v-model:current-time-entry="currentTimeEntry"
|
<div class="flex w-full items-center gap-2">
|
||||||
v-model:live-timer="now"
|
<div class="flex-1">
|
||||||
:create-project
|
<TimeTrackerControls
|
||||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
v-model:current-time-entry="currentTimeEntry"
|
||||||
:can-create-project="canCreateProjects()"
|
v-model:live-timer="now"
|
||||||
:create-client
|
:create-project
|
||||||
:clients
|
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||||
:tags
|
:can-create-project="canCreateProjects()"
|
||||||
:tasks
|
:create-client
|
||||||
:projects
|
:clients
|
||||||
:create-tag
|
:tags
|
||||||
:is-active
|
:tasks
|
||||||
:currency="getOrganizationCurrencyString()"
|
:projects
|
||||||
@start-live-timer="startLiveTimer"
|
:create-tag
|
||||||
@stop-live-timer="stopLiveTimer"
|
:is-active
|
||||||
@start-timer="setActiveState(true)"
|
:currency="getOrganizationCurrencyString()"
|
||||||
@stop-timer="setActiveState(false)"
|
@start-live-timer="startLiveTimer"
|
||||||
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
@stop-live-timer="stopLiveTimer"
|
||||||
|
@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>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type Project,
|
type Project,
|
||||||
type TimeEntryResponse,
|
type TimeEntryResponse,
|
||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } 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,6 +73,7 @@ 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(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ 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';
|
||||||
@@ -24,7 +22,6 @@ 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';
|
||||||
@@ -73,7 +70,6 @@ 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();
|
||||||
@@ -105,33 +101,9 @@ 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">
|
||||||
<div
|
<TimeTracker></TimeTracker>
|
||||||
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"
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ 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()"
|
||||||
|
|||||||
@@ -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,31 +17,42 @@ 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(),
|
||||||
getDayJsInstance()(tempEnd.value).utc().format()
|
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropdownContent = ref();
|
function setEndTime() {
|
||||||
const { focused } = useFocusWithin(dropdownContent);
|
showEndTimePicker.value = true;
|
||||||
|
tempEnd.value = getDayJsInstance()().format();
|
||||||
|
}
|
||||||
|
|
||||||
watch(focused, (newValue, oldValue) => {
|
function confirmEndTime() {
|
||||||
if (oldValue === true && newValue === false) {
|
// wait for the v-model for the end time to update
|
||||||
|
nextTick(() => {
|
||||||
updateTimeEntry();
|
updateTimeEntry();
|
||||||
}
|
showEndTimePicker.value = false;
|
||||||
});
|
emit('close');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropdownContent = ref();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -67,7 +78,7 @@ watch(focused, (newValue, oldValue) => {
|
|||||||
</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="tempEnd !== null" class="space-y-2">
|
<div v-if="end !== null && 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"
|
||||||
@@ -77,6 +88,22 @@ watch(focused, (newValue, oldValue) => {
|
|||||||
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>
|
||||||
|
|||||||
@@ -68,19 +68,6 @@ 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() : '',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const emit = defineEmits<{
|
|||||||
updateTimeEntry: [];
|
updateTimeEntry: [];
|
||||||
startLiveTimer: [];
|
startLiveTimer: [];
|
||||||
stopLiveTimer: [];
|
stopLiveTimer: [];
|
||||||
|
createTimeEntry: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function updateProject() {
|
function updateProject() {
|
||||||
@@ -280,6 +281,7 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<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>
|
||||||
@@ -16,6 +16,7 @@ const emit = defineEmits<{
|
|||||||
stopLiveTimer: [];
|
stopLiveTimer: [];
|
||||||
updateTimer: [];
|
updateTimer: [];
|
||||||
startTimer: [];
|
startTimer: [];
|
||||||
|
createTimeEntry: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
@@ -55,7 +56,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');
|
||||||
@@ -73,12 +74,16 @@ function updateTimerAndStartLiveTimerUpdate() {
|
|||||||
|
|
||||||
const temporaryCustomTimerEntry = ref<string>('');
|
const temporaryCustomTimerEntry = ref<string>('');
|
||||||
|
|
||||||
async function updateTimeRange(newStart: string) {
|
async function updateTimeRange(newStart: string, newEnd: string | null) {
|
||||||
// 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');
|
||||||
}
|
}
|
||||||
@@ -91,11 +96,21 @@ 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)) {
|
||||||
@@ -103,6 +118,12 @@ 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();
|
||||||
@@ -135,8 +156,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"
|
||||||
@@ -146,7 +167,7 @@ function closeAndFocusInput() {
|
|||||||
<div ref="timeRangeSelector">
|
<div ref="timeRangeSelector">
|
||||||
<TimeRangeSelector
|
<TimeRangeSelector
|
||||||
:start="startTime"
|
:start="startTime"
|
||||||
:end="null"
|
:end="endTime"
|
||||||
@changed="updateTimeRange"
|
@changed="updateTimeRange"
|
||||||
@close="closeAndFocusInput">
|
@close="closeAndFocusInput">
|
||||||
</TimeRangeSelector>
|
</TimeRangeSelector>
|
||||||
|
|||||||
@@ -208,22 +208,30 @@ 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 { seconds: Math.round(hours * 3600), isHHMM: false };
|
return Math.round(hours * 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
return 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
|
||||||
@@ -233,15 +241,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 { seconds: (hours * 60 + minutes) * 60, isHHMM: true };
|
return (hours * 60 + minutes) * 60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 { seconds: parsedDuration, isHHMM: false };
|
return parsedDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { seconds: null, isHHMM: false };
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: null,
|
end: currentTimeEntry.value.end,
|
||||||
tags: currentTimeEntry.value.tags,
|
tags: currentTimeEntry.value.tags,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -175,7 +175,12 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
'Time entry updated!'
|
'Time entry updated!'
|
||||||
);
|
);
|
||||||
if (response?.data) {
|
if (response?.data) {
|
||||||
currentTimeEntry.value = response.data;
|
if (response.data.end === null) {
|
||||||
|
currentTimeEntry.value = response.data;
|
||||||
|
} else {
|
||||||
|
$reset();
|
||||||
|
stopLiveTimer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -215,5 +220,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
stopLiveTimer,
|
stopLiveTimer,
|
||||||
now,
|
now,
|
||||||
setActiveState,
|
setActiveState,
|
||||||
|
$reset,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
114
tests/Unit/Console/KernelTest.php
Normal file
114
tests/Unit/Console/KernelTest.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user