mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
6 Commits
pullreques
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4d3ec061 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 |
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,10 @@ async function goToOrganizationSettings(page) {
|
||||
|
||||
async function createTimeEntry(page, duration: string) {
|
||||
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
|
||||
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
|
||||
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
|
||||
await page
|
||||
@@ -52,7 +55,10 @@ async function createTimeEntryWithProject(page: Page, projectName: string, durat
|
||||
|
||||
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
|
||||
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
|
||||
await page
|
||||
@@ -81,7 +87,10 @@ async function createTimeEntryWithBillableStatus(
|
||||
duration: string
|
||||
) {
|
||||
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
|
||||
await page
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,12 +16,25 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
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 TimeTrackerMoreOptionsDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
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<{
|
||||
auth: {
|
||||
@@ -47,6 +60,8 @@ const emit = defineEmits<{
|
||||
change: [];
|
||||
}>();
|
||||
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
|
||||
watch(isActive, () => {
|
||||
if (isActive.value) {
|
||||
startLiveTimer();
|
||||
@@ -93,14 +108,70 @@ function switchToTimeEntryOrganization() {
|
||||
switchOrganization(currentTimeEntry.value.organization_id);
|
||||
}
|
||||
}
|
||||
async function createTag(tag: string) {
|
||||
async function createTag(tag: string): Promise<Tag | undefined> {
|
||||
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());
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<div class="relative">
|
||||
<TimeTrackerRunningInDifferentOrganizationOverlay
|
||||
@@ -109,24 +180,35 @@ const { tags } = storeToRefs(useTagsStore());
|
||||
switchToTimeEntryOrganization
|
||||
"></TimeTrackerRunningInDifferentOrganizationOverlay>
|
||||
|
||||
<TimeTrackerControls
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="now"
|
||||
:create-project
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-client
|
||||
:clients
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@stop-live-timer="stopLiveTimer"
|
||||
@start-timer="setActiveState(true)"
|
||||
@stop-timer="setActiveState(false)"
|
||||
@update-time-entry="updateTimeEntry"></TimeTrackerControls>
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<TimeTrackerControls
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="now"
|
||||
:create-project
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-client
|
||||
:clients
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
@start-live-timer="startLiveTimer"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import { useElementVisibility } from '@vueuse/core';
|
||||
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 { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
@@ -24,7 +22,6 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
|
||||
import { useTagsStore } from '@/utils/useTags';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||
@@ -73,7 +70,6 @@ onMounted(async () => {
|
||||
await timeEntriesStore.fetchTimeEntries();
|
||||
});
|
||||
|
||||
const showManualTimeEntryModal = ref(false);
|
||||
const projectStore = useProjectsStore();
|
||||
const { projects } = storeToRefs(projectStore);
|
||||
const taskStore = useTasksStore();
|
||||
@@ -105,33 +101,9 @@ function deleteSelected() {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<MainContainer class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||
<div
|
||||
class="lg:flex items-end lg:divide-x divide-default-background-separator divide-y lg:divide-y-0 space-y-2 lg:space-y-0 lg:space-x-2">
|
||||
<div class="flex-1">
|
||||
<TimeTracker></TimeTracker>
|
||||
</div>
|
||||
<div class="pb-2 pt-2 lg:pt-0 lg:pl-4 flex justify-center">
|
||||
<SecondaryButton
|
||||
class="w-full text-center flex justify-center"
|
||||
:icon="PlusIcon"
|
||||
@click="showManualTimeEntryModal = true"
|
||||
>Manual time entry
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
<TimeTracker></TimeTracker>
|
||||
</MainContainer>
|
||||
<TimeEntryMassActionRow
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [];
|
||||
updateTimer: [];
|
||||
startTimer: [];
|
||||
createTimeEntry: [];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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