mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
11 Commits
feature/ad
...
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'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
||||
// Convert string to a stable integer for seeding
|
||||
/** @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')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||
->twiceDaily();
|
||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->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')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||
|
||||
@@ -38,11 +38,17 @@ class ClientController extends Controller
|
||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:view');
|
||||
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
|
||||
$user = $this->user();
|
||||
|
||||
$clientsQuery = Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! $canViewAllClients) {
|
||||
$clientsQuery->visibleByEmployee($user);
|
||||
}
|
||||
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$clientsQuery->whereNotNull('archived_at');
|
||||
|
||||
@@ -42,7 +42,7 @@ class HandleInertiaRequests extends Middleware
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
|
||||
$hasServices = Module::has('Services') && Module::isEnabled('Services');
|
||||
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'changes.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'changes.tags' => [
|
||||
|
||||
@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Models;
|
||||
use App\Models\Concerns\CustomAuditable;
|
||||
use App\Models\Concerns\HasUuids;
|
||||
use Database\Factories\ClientFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
|
||||
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>
|
||||
*/
|
||||
|
||||
@@ -109,6 +109,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -172,6 +173,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -232,6 +234,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -256,12 +259,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'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', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
|
||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$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');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
|
||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$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');
|
||||
}
|
||||
$timeEntry->description = $record['Notes'];
|
||||
|
||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$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');
|
||||
}
|
||||
$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 (
|
||||
id uuid NOT NULL,
|
||||
description character varying(500) NOT NULL,
|
||||
description character varying(5000) NOT NULL,
|
||||
start timestamp(0) without time zone NOT NULL,
|
||||
"end" timestamp(0) without time zone,
|
||||
billable_rate integer,
|
||||
|
||||
@@ -10,7 +10,8 @@ defineProps<{
|
||||
<div class="px-4 py-2 2xl:py-3 border-b border-b-background-separator">
|
||||
<div class="col-span-2">
|
||||
<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 }}
|
||||
</p>
|
||||
<div v-if="working" class="flex space-x-1.5 items-center justify-end">
|
||||
|
||||
@@ -117,6 +117,12 @@ async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
|
||||
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();
|
||||
|
||||
@@ -195,7 +201,8 @@ const { tags } = storeToRefs(useTagsStore());
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||
@update-time-entry="updateTimeEntry"
|
||||
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
|
||||
</div>
|
||||
<TimeTrackerMoreOptionsDropdown
|
||||
:has-active-timer="isActive"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type Project,
|
||||
type TimeEntryResponse,
|
||||
} from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { TimeEntryCalendar } from '@/packages/ui/src';
|
||||
@@ -73,6 +73,7 @@ const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useQuery<Time
|
||||
queries: {
|
||||
start: expandedDateRange.value.start!,
|
||||
end: expandedDateRange.value.end!,
|
||||
member_id: getCurrentMembershipId(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ const inputValue = ref(model.value ? getLocalizedDayJs(model.value).format('HH:m
|
||||
data-testid="time_picker_input"
|
||||
type="text"
|
||||
@blur="updateTime"
|
||||
@keydown.enter.prevent="updateTime"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
@mouseup="($event.target as HTMLInputElement).select()"
|
||||
@click="($event.target as HTMLInputElement).select()"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, nextTick, ref, watch } from 'vue';
|
||||
import { useFocusWithin } from '@vueuse/core';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
@@ -17,31 +17,42 @@ const emit = defineEmits(['changed', 'close']);
|
||||
|
||||
const tempStart = ref(props.start ? getLocalizedDayJs(props.start).format() : dayjs().format());
|
||||
const tempEnd = ref(props.end ? getLocalizedDayJs(props.end).format() : null);
|
||||
const showEndTimePicker = ref(false);
|
||||
|
||||
watch(props, () => {
|
||||
tempStart.value = getLocalizedDayJs(props.start).format();
|
||||
tempEnd.value = props.end ? getLocalizedDayJs(props.end).format() : null;
|
||||
showEndTimePicker.value = false;
|
||||
});
|
||||
|
||||
function updateTimeEntry() {
|
||||
const tempStartUtc = getDayJsInstance()(tempStart.value).utc().format();
|
||||
const tempEndUtc = tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null;
|
||||
|
||||
if (tempStartUtc !== props.start || tempEndUtc !== props.end) {
|
||||
emit(
|
||||
'changed',
|
||||
getDayJsInstance()(tempStart.value).utc().format(),
|
||||
getDayJsInstance()(tempEnd.value).utc().format()
|
||||
tempEnd.value ? getDayJsInstance()(tempEnd.value).utc().format() : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
const { focused } = useFocusWithin(dropdownContent);
|
||||
function setEndTime() {
|
||||
showEndTimePicker.value = true;
|
||||
tempEnd.value = getDayJsInstance()().format();
|
||||
}
|
||||
|
||||
watch(focused, (newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
function confirmEndTime() {
|
||||
// wait for the v-model for the end time to update
|
||||
nextTick(() => {
|
||||
updateTimeEntry();
|
||||
}
|
||||
});
|
||||
showEndTimePicker.value = false;
|
||||
emit('close');
|
||||
});
|
||||
}
|
||||
|
||||
const dropdownContent = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,7 +78,7 @@ watch(focused, (newValue, oldValue) => {
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<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
|
||||
v-model="tempEnd"
|
||||
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"
|
||||
@changed="updateTimeEntry"></DatePicker>
|
||||
</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 tabindex="0" @focusin="emit('close')"></div>
|
||||
</div>
|
||||
|
||||
@@ -68,19 +68,6 @@ watch(
|
||||
{ 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({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
|
||||
|
||||
@@ -29,7 +29,7 @@ const open = ref(false);
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const defaultUnit =
|
||||
organizationSettings?.value?.intervalFormat === 'decimal' ? 'hours' : 'minutes';
|
||||
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, defaultUnit);
|
||||
if (seconds && seconds > 0) {
|
||||
let newEndDate = props.end;
|
||||
let newStartDate = props.start;
|
||||
|
||||
@@ -49,6 +49,7 @@ const emit = defineEmits<{
|
||||
updateTimeEntry: [];
|
||||
startLiveTimer: [];
|
||||
stopLiveTimer: [];
|
||||
createTimeEntry: [];
|
||||
}>();
|
||||
|
||||
function updateProject() {
|
||||
@@ -280,6 +281,7 @@ useSelectEvents(
|
||||
@stop-live-timer="emit('stopLiveTimer')"
|
||||
@update-timer="emit('updateTimeEntry')"
|
||||
@start-timer="emit('startTimer')"
|
||||
@create-time-entry="emit('createTimeEntry')"
|
||||
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ const emit = defineEmits<{
|
||||
stopLiveTimer: [];
|
||||
updateTimer: [];
|
||||
startTimer: [];
|
||||
createTimeEntry: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
@@ -55,7 +56,7 @@ const currentTime = computed({
|
||||
});
|
||||
|
||||
function updateTimerAndStartLiveTimerUpdate() {
|
||||
const { seconds } = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||
const seconds = parseTimeInput(temporaryCustomTimerEntry.value, 'minutes');
|
||||
|
||||
if (seconds && seconds > 0) {
|
||||
const newStartDate = dayjs().subtract(seconds, 's');
|
||||
@@ -73,12 +74,16 @@ function updateTimerAndStartLiveTimerUpdate() {
|
||||
|
||||
const temporaryCustomTimerEntry = ref<string>('');
|
||||
|
||||
async function updateTimeRange(newStart: string) {
|
||||
async function updateTimeRange(newStart: string, newEnd: string | null) {
|
||||
// prohibit updates in the future
|
||||
if (getDayJsInstance()(newStart).isBefore(getDayJsInstance()())) {
|
||||
currentTimeEntry.value.start = newStart;
|
||||
currentTimeEntry.value.end = newEnd;
|
||||
if (currentTimeEntry.value.id) {
|
||||
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 {
|
||||
emit('startTimer');
|
||||
}
|
||||
@@ -91,11 +96,21 @@ const startTime = computed(() => {
|
||||
}
|
||||
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 timeRangeSelector = ref<HTMLElement | null>(null);
|
||||
|
||||
function openModalOnTab(e: FocusEvent) {
|
||||
pauseLiveTimerUpdate(e);
|
||||
|
||||
// check if the source is inside the dropdown
|
||||
const source = e.relatedTarget as HTMLElement;
|
||||
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) {
|
||||
if (open.value) {
|
||||
e.preventDefault();
|
||||
@@ -135,8 +156,8 @@ function closeAndFocusInput() {
|
||||
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"
|
||||
type="text"
|
||||
@focus="pauseLiveTimerUpdate"
|
||||
@focusin="openModalOnTab"
|
||||
@click="openModalOnClick"
|
||||
@keydown.exact.tab="focusNextElement"
|
||||
@keydown.exact.shift.tab="open = false"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
@@ -146,7 +167,7 @@ function closeAndFocusInput() {
|
||||
<div ref="timeRangeSelector">
|
||||
<TimeRangeSelector
|
||||
:start="startTime"
|
||||
:end="null"
|
||||
:end="endTime"
|
||||
@changed="updateTimeRange"
|
||||
@close="closeAndFocusInput">
|
||||
</TimeRangeSelector>
|
||||
|
||||
@@ -208,22 +208,30 @@ export function formatStartEnd(
|
||||
export function parseTimeInput(
|
||||
input: string,
|
||||
defaultUnit: TimeInputUnit = 'minutes'
|
||||
): {
|
||||
seconds: number | null;
|
||||
isHHMM: boolean;
|
||||
} {
|
||||
): number | null {
|
||||
// Check if input is a decimal number (hours)
|
||||
const decimalRegex = /^-?\d+[.,]\d+$/;
|
||||
if (decimalRegex.test(input)) {
|
||||
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)
|
||||
if (/^-?\d+$/.test(input)) {
|
||||
const value = parseInt(input);
|
||||
const seconds = defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
||||
return { seconds, isHHMM: false };
|
||||
return defaultUnit === 'minutes' ? value * 60 : value * 3600;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -233,15 +241,15 @@ export function parseTimeInput(
|
||||
if (match) {
|
||||
const hours = parseInt(match[1]);
|
||||
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"
|
||||
const parsedDuration = parse(input, 's');
|
||||
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,
|
||||
start: currentTimeEntry.value.start,
|
||||
billable: currentTimeEntry.value.billable,
|
||||
end: null,
|
||||
end: currentTimeEntry.value.end,
|
||||
tags: currentTimeEntry.value.tags,
|
||||
},
|
||||
{
|
||||
@@ -175,7 +175,12 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
'Time entry updated!'
|
||||
);
|
||||
if (response?.data) {
|
||||
currentTimeEntry.value = response.data;
|
||||
if (response.data.end === null) {
|
||||
currentTimeEntry.value = response.data;
|
||||
} else {
|
||||
$reset();
|
||||
stopLiveTimer();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -215,5 +220,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
stopLiveTimer,
|
||||
now,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
]);
|
||||
$clients = Client::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
|
||||
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
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
]);
|
||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||
@@ -81,6 +114,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
]);
|
||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||
@@ -103,6 +137,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
]);
|
||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||
@@ -125,6 +160,7 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
]);
|
||||
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
|
||||
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
|
||||
|
||||
Reference in New Issue
Block a user