add notice when time tracker is running in a different organization

This commit is contained in:
Gregor Vostrak
2024-04-26 02:10:45 +02:00
parent 3c158cc015
commit bb5ccf3606
9 changed files with 237 additions and 81 deletions

View File

@@ -256,7 +256,7 @@ test('test that updating a the duration in the overview works on blur', async ({
await expect( await expect(
newTimeEntry.getByTestId('time_entry_duration_input') newTimeEntry.getByTestId('time_entry_duration_input')
).toHaveValue('00h 20min'); ).toHaveValue('0h 20min');
}); });
// Test that start stop button stops running timer // Test that start stop button stops running timer

View File

@@ -128,6 +128,7 @@ const TimeEntryResource = z
description: z.union([z.string(), z.null()]), description: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]), task_id: z.union([z.string(), z.null()]),
project_id: z.union([z.string(), z.null()]), project_id: z.union([z.string(), z.null()]),
organization_id: z.string(),
user_id: z.string(), user_id: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
billable: z.boolean(), billable: z.boolean(),
@@ -391,6 +392,17 @@ const endpoints = makeApi([
], ],
response: z.null(), response: z.null(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,
@@ -623,6 +635,78 @@ const endpoints = makeApi([
}, },
], ],
}, },
{
method: 'delete',
path: '/v1/organizations/:organization/invitations/:invitation',
alias: 'removeInvitation',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'invitation',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.null(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/invitations/:invitation/resend',
alias: 'resendInvitationEmail',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({}).partial().passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string().uuid(),
},
{
name: 'invitation',
type: 'Path',
schema: z.string().uuid(),
},
],
response: z.null(),
errors: [
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/members', path: '/v1/organizations/:organization/members',
@@ -761,6 +845,17 @@ const endpoints = makeApi([
], ],
response: z.null(), response: z.null(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,
@@ -1102,6 +1197,17 @@ const endpoints = makeApi([
], ],
response: z.null(), response: z.null(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,
@@ -1371,6 +1477,17 @@ const endpoints = makeApi([
], ],
response: z.null(), response: z.null(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,
@@ -1567,6 +1684,17 @@ const endpoints = makeApi([
], ],
response: z.null(), response: z.null(),
errors: [ errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{ {
status: 403, status: 403,
description: `Authorization error`, description: `Authorization error`,

View File

@@ -79,7 +79,6 @@ function updateSearchValue(event: Event) {
const emit = defineEmits(['update:modelValue', 'changed']); const emit = defineEmits(['update:modelValue', 'changed']);
function updateMember(newValue: string | null) { function updateMember(newValue: string | null) {
console.log(newValue);
if (newValue) { if (newValue) {
model.value = newValue; model.value = newValue;
nextTick(() => { nextTick(() => {

View File

@@ -136,7 +136,6 @@ function moveHighlightDown() {
filteredTags.value[currentHightlightedIndex + 1].id; filteredTags.value[currentHightlightedIndex + 1].id;
} }
} }
console.log('move down');
} }
const highlightedItemId = ref<string | null>(null); const highlightedItemId = ref<string | null>(null);

View File

@@ -2,11 +2,11 @@
import { ChevronDownIcon } from '@heroicons/vue/20/solid'; import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue'; import DropdownLink from '@/Components/DropdownLink.vue';
import { router, usePage } from '@inertiajs/vue3'; import { usePage } from '@inertiajs/vue3';
import type { Organization, User } from '@/types/models'; import type { Organization, User } from '@/types/models';
import { isBillingActivated } from '@/utils/billing'; import { isBillingActivated } from '@/utils/billing';
import { initializeStores } from '@/utils/init';
import { canUpdateOrganization } from '@/utils/permissions'; import { canUpdateOrganization } from '@/utils/permissions';
import { switchOrganization } from '@/utils/useOrganization';
const page = usePage<{ const page = usePage<{
jetstream: { jetstream: {
@@ -22,19 +22,8 @@ const page = usePage<{
}; };
}>(); }>();
const switchToTeam = (team: Organization) => { const switchToTeam = (organization: Organization) => {
router.put( switchOrganization(organization.id);
route('current-team.update'),
{
team_id: team.id,
},
{
preserveState: false,
onSuccess: () => {
initializeStores();
},
}
);
}; };
</script> </script>

View File

@@ -15,6 +15,9 @@ import { storeToRefs } from 'pinia';
import parse from 'parse-duration'; import parse from 'parse-duration';
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue'; import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue'; import TimeTrackerProjectTaskDropdown from '@/Components/Common/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { switchOrganization } from '@/utils/useOrganization';
import SecondaryButton from '@/Components/SecondaryButton.vue';
const page = usePage<{ const page = usePage<{
auth: { auth: {
@@ -148,62 +151,93 @@ function onTimeEntryEnterPress() {
const activeElement = document.activeElement as HTMLElement; const activeElement = document.activeElement as HTMLElement;
activeElement?.blur(); activeElement?.blur();
} }
const isRunningInDifferentOrganization = computed(() => {
return (
currentTimeEntry.value.organization_id &&
getCurrentOrganizationId() &&
currentTimeEntry.value.organization_id !== getCurrentOrganizationId()
);
});
function switchToTimeEntryOrganization() {
if (currentTimeEntry.value.organization_id) {
switchOrganization(currentTimeEntry.value.organization_id);
}
}
</script> </script>
<template> <template>
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle> <CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
<div class="flex items-center relative" data-testid="dashboard_timer"> <div class="relative">
<div <div
class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition"> class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center"
<div class="flex-1 flex items-center pr-6"> v-if="isRunningInDifferentOrganization">
<input <div
placeholder="What are you working on?" class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div>
data-testid="time_entry_description" <div class="flex space-x-3 items-center w-full z-20 justify-center">
v-model="currentTimeEntry.description" <span class="text-sm text-white">
@keydown.enter="startTimerIfNotActive" The Timer is running in a different organization.
@blur="updateTimeEntry" </span>
class="w-full rounded-l-lg py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-sm sm:text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition" <SecondaryButton @click="switchToTimeEntryOrganization"
type="text" /> >Switch to organization</SecondaryButton
</div> >
<div class="flex items-center justify-between pl-2">
<div class="flex items-center w-[130px] sm:w-auto">
<TimeTrackerProjectTaskDropdown
@changed="updateTimeEntry"
v-model:project="currentTimeEntry.project_id"
v-model:task="
currentTimeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown
@changed="updateTimeEntry"
v-model="
currentTimeEntry.tags
"></TimeTrackerTagDropdown>
<BillableToggleButton
@changed="updateTimeEntry"
v-model="
currentTimeEntry.billable
"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<input
placeholder="00:00:00"
@focus="pauseLiveTimerUpdate"
data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress"
v-model="currentTime"
class="w-[110px] sm:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-sm sm:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active"
type="text" />
</div>
</div> </div>
</div> </div>
<div class="pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0"> <div class="flex items-center relative" data-testid="dashboard_timer">
<TimeTrackerStartStop <div
:active="isActive" class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition">
@changed="onToggleButtonPress" <div class="flex-1 flex items-center pr-6">
size="large"></TimeTrackerStartStop> <input
placeholder="What are you working on?"
data-testid="time_entry_description"
v-model="currentTimeEntry.description"
@keydown.enter="startTimerIfNotActive"
@blur="updateTimeEntry"
class="w-full rounded-l-lg py-2.5 px-3 border-b border-b-card-background-separator sm:px-4 text-sm sm:text-lg text-white focus:bg-card-background-active font-medium bg-transparent border-none placeholder-muted focus:ring-0 transition"
type="text" />
</div>
<div class="flex items-center justify-between pl-2">
<div class="flex items-center w-[130px] sm:w-auto">
<TimeTrackerProjectTaskDropdown
@changed="updateTimeEntry"
v-model:project="currentTimeEntry.project_id"
v-model:task="
currentTimeEntry.task_id
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2 px-4">
<TimeTrackerTagDropdown
@changed="updateTimeEntry"
v-model="
currentTimeEntry.tags
"></TimeTrackerTagDropdown>
<BillableToggleButton
@changed="updateTimeEntry"
v-model="
currentTimeEntry.billable
"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<input
placeholder="00:00:00"
@focus="pauseLiveTimerUpdate"
data-testid="time_entry_time"
@blur="updateTimerAndStartLiveTimerUpdate"
@keydown.enter="onTimeEntryEnterPress"
v-model="currentTime"
class="w-[110px] sm:w-[130px] h-full text-white py-2.5 rounded-r-lg text-center px-4 text-sm sm:text-lg font-bold bg-card-background border-none placeholder-muted focus:ring-0 transition focus:bg-card-background-active"
type="text" />
</div>
</div>
</div>
<div
class="pl-6 pr-3 absolute sm:relative top-[6px] sm:top-0 right-0">
<TimeTrackerStartStop
:active="isActive"
@changed="onToggleButtonPress"
size="large"></TimeTrackerStartStop>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -7,7 +7,6 @@ const page = usePage<{
}>(); }>();
function currentUserHasPermission(permission: string) { function currentUserHasPermission(permission: string) {
console.log(page.props.auth.permissions);
if (Array.isArray(page.props.auth.permissions)) { if (Array.isArray(page.props.auth.permissions)) {
return page.props.auth.permissions.includes(permission); return page.props.auth.permissions.includes(permission);
} }

View File

@@ -22,6 +22,7 @@ const emptyTimeEntry = {
project_id: null, project_id: null,
tags: [], tags: [],
billable: false, billable: false,
organization_id: '',
} as TimeEntry; } as TimeEntry;
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => { export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
@@ -56,20 +57,10 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
async function fetchCurrentTimeEntry() { async function fetchCurrentTimeEntry() {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
const timeEntriesResponse = await handleApiRequestNotifications( const timeEntriesResponse = await api.getMyActiveTimeEntry({});
api.getTimeEntries({
queries: {
active: 'true',
user_id: getCurrentUserId(),
},
params: {
organization: organizationId,
},
})
);
if (timeEntriesResponse?.data) { if (timeEntriesResponse?.data) {
if (timeEntriesResponse.data.length === 1) { if (timeEntriesResponse.data) {
currentTimeEntry.value = timeEntriesResponse.data[0]; currentTimeEntry.value = timeEntriesResponse.data;
} else { } else {
currentTimeEntry.value = { ...emptyTimeEntry }; currentTimeEntry.value = { ...emptyTimeEntry };
} }

View File

@@ -0,0 +1,17 @@
import { router } from '@inertiajs/vue3';
import { initializeStores } from '@/utils/init';
export function switchOrganization(organizationId: string) {
router.put(
route('current-team.update'),
{
team_id: organizationId,
},
{
preserveState: false,
onSuccess: () => {
initializeStores();
},
}
);
}