mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
add notice when time tracker is running in a different organization
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
17
resources/js/utils/useOrganization.ts
Normal file
17
resources/js/utils/useOrganization.ts
Normal 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();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user