Compare commits

..

11 Commits

26 changed files with 335 additions and 57 deletions

View File

@@ -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'))

View File

@@ -38,11 +38,17 @@ class ClientController extends Controller
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{ {
$this->checkPermission($organization, 'clients:view'); $this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();
$clientsQuery = Client::query() $clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived(); $filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') { if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at'); $clientsQuery->whereNotNull('archived_at');

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:500', 'max:5000',
], ],
// 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:500', 'max:5000',
], ],
// 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:500', 'max:5000',
], ],
// List of tag IDs // List of tag IDs
'tags' => [ 'tags' => [

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory; use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
return $this->hasMany(Project::class, 'client_id'); return $this->hasMany(Project::class, 'client_id');
} }
/**
* @param Builder<Client> $builder
* @return Builder<Client>
*/
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
{
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
/** @var Builder<Project> $builder */
return $builder->visibleByEmployee($user);
});
}
/** /**
* @return Attribute<bool, never> * @return Attribute<bool, never>
*/ */

View File

@@ -109,6 +109,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update', 'tags:update',
'tags:delete', 'tags:delete',
'clients:view', 'clients:view',
'clients:view:all',
'clients:create', 'clients:create',
'clients:update', 'clients:update',
'clients:delete', 'clients:delete',
@@ -172,6 +173,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update', 'tags:update',
'tags:delete', 'tags:delete',
'clients:view', 'clients:view',
'clients:view:all',
'clients:create', 'clients:create',
'clients:update', 'clients:update',
'clients:delete', 'clients:delete',
@@ -232,6 +234,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update', 'tags:update',
'tags:delete', 'tags:delete',
'clients:view', 'clients:view',
'clients:view:all',
'clients:create', 'clients:create',
'clients:update', 'clients:update',
'clients:delete', 'clients:delete',
@@ -256,12 +259,13 @@ class JetstreamServiceProvider extends ServiceProvider
'projects:view', 'projects:view',
'tags:view', 'tags:view',
'tasks:view', 'tasks:view',
'clients:view',
'time-entries:view:own', 'time-entries:view:own',
'time-entries:create:own', 'time-entries:create:own',
'time-entries:update:own', 'time-entries:update:own',
'time-entries:delete:own', 'time-entries:delete:own',
'organizations:view', 'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.'); ])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
Jetstream::role(Role::Placeholder->value, 'Placeholder', [ Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');

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']) > 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'];

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']) > 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'];

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']) > 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'];

View File

@@ -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();
});
}
};

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(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,

View File

@@ -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">

View File

@@ -117,6 +117,12 @@ async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
showManualTimeEntryModal.value = false; 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 { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -195,7 +201,8 @@ const { tags } = storeToRefs(useTagsStore());
@stop-live-timer="stopLiveTimer" @stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)" @start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)" @stop-timer="setActiveState(false)"
@update-time-entry="updateTimeEntry"></TimeTrackerControls> @update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
</div> </div>
<TimeTrackerMoreOptionsDropdown <TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive" :has-active-timer="isActive"

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 } 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(),
}, },
}), }),
}); });

View File

@@ -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()"

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,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>

View File

@@ -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() : '',

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,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>

View File

@@ -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>

View File

@@ -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;
} }

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: 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,
}; };
}); });

View 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);
}
}

View File

@@ -34,6 +34,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4); $clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
Passport::actingAs($data->user); Passport::actingAs($data->user);
@@ -57,11 +58,43 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
); );
} }
public function test_index_endpoint_returns_list_of_clients_assigned_to_employee_user(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$clients = Client::factory()->forOrganization($data->organization)->createMany(2);
$projectWithMembership1 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(0))->addMember($data->member)->isPrivate()->create();
$projectWithMembership2 = Project::factory()->forOrganization($data->organization)->forClient($clients->get(1))->addMember($data->member)->isPrivate()->create();
$otherClients = Client::factory()->forOrganization($data->organization)->createMany(2);
$projectWithoutMembership = Project::factory()->forOrganization($data->organization)->forClient($otherClients->get(0))->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('links')
->has('meta')
->count('data', 2)
->where('data.0.id', $clients->get(0)->getKey())
->where('data.1.id', $clients->get(1)->getKey())
);
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -81,6 +114,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -103,6 +137,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
@@ -125,6 +160,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
// Arrange // Arrange
$data = $this->createUserWithPermission([ $data = $this->createUserWithPermission([
'clients:view', 'clients:view',
'clients:view:all',
]); ]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2); $archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2); $nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);