mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00: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(
|
||||
newTimeEntry.getByTestId('time_entry_duration_input')
|
||||
).toHaveValue('00h 20min');
|
||||
).toHaveValue('0h 20min');
|
||||
});
|
||||
|
||||
// Test that start stop button stops running timer
|
||||
|
||||
@@ -128,6 +128,7 @@ const TimeEntryResource = z
|
||||
description: z.union([z.string(), z.null()]),
|
||||
task_id: z.union([z.string(), z.null()]),
|
||||
project_id: z.union([z.string(), z.null()]),
|
||||
organization_id: z.string(),
|
||||
user_id: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
billable: z.boolean(),
|
||||
@@ -391,6 +392,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
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',
|
||||
path: '/v1/organizations/:organization/members',
|
||||
@@ -761,6 +845,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
@@ -1102,6 +1197,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
@@ -1371,6 +1477,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
@@ -1567,6 +1684,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
|
||||
@@ -79,7 +79,6 @@ function updateSearchValue(event: Event) {
|
||||
const emit = defineEmits(['update:modelValue', 'changed']);
|
||||
|
||||
function updateMember(newValue: string | null) {
|
||||
console.log(newValue);
|
||||
if (newValue) {
|
||||
model.value = newValue;
|
||||
nextTick(() => {
|
||||
|
||||
@@ -136,7 +136,6 @@ function moveHighlightDown() {
|
||||
filteredTags.value[currentHightlightedIndex + 1].id;
|
||||
}
|
||||
}
|
||||
console.log('move down');
|
||||
}
|
||||
|
||||
const highlightedItemId = ref<string | null>(null);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import Dropdown from '@/Components/Dropdown.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 { isBillingActivated } from '@/utils/billing';
|
||||
import { initializeStores } from '@/utils/init';
|
||||
import { canUpdateOrganization } from '@/utils/permissions';
|
||||
import { switchOrganization } from '@/utils/useOrganization';
|
||||
|
||||
const page = usePage<{
|
||||
jetstream: {
|
||||
@@ -22,19 +22,8 @@ const page = usePage<{
|
||||
};
|
||||
}>();
|
||||
|
||||
const switchToTeam = (team: Organization) => {
|
||||
router.put(
|
||||
route('current-team.update'),
|
||||
{
|
||||
team_id: team.id,
|
||||
},
|
||||
{
|
||||
preserveState: false,
|
||||
onSuccess: () => {
|
||||
initializeStores();
|
||||
},
|
||||
}
|
||||
);
|
||||
const switchToTeam = (organization: Organization) => {
|
||||
switchOrganization(organization.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import { storeToRefs } from 'pinia';
|
||||
import parse from 'parse-duration';
|
||||
import TimeTrackerTagDropdown from '@/Components/Common/TimeTracker/TimeTrackerTagDropdown.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<{
|
||||
auth: {
|
||||
@@ -148,62 +151,93 @@ function onTimeEntryEnterPress() {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
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>
|
||||
|
||||
<template>
|
||||
<CardTitle title="Time Tracker" :icon="ClockIcon"></CardTitle>
|
||||
<div class="flex items-center relative" data-testid="dashboard_timer">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition">
|
||||
<div class="flex-1 flex items-center pr-6">
|
||||
<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>
|
||||
class="absolute w-full h-full backdrop-blur-sm z-10 flex items-center justify-center"
|
||||
v-if="isRunningInDifferentOrganization">
|
||||
<div
|
||||
class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div>
|
||||
<div class="flex space-x-3 items-center w-full z-20 justify-center">
|
||||
<span class="text-sm text-white">
|
||||
The Timer is running in a different organization.
|
||||
</span>
|
||||
<SecondaryButton @click="switchToTimeEntryOrganization"
|
||||
>Switch to organization</SecondaryButton
|
||||
>
|
||||
</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 class="flex items-center relative" data-testid="dashboard_timer">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row w-full rounded-lg bg-card-background border-card-border border transition">
|
||||
<div class="flex-1 flex items-center pr-6">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,6 @@ const page = usePage<{
|
||||
}>();
|
||||
|
||||
function currentUserHasPermission(permission: string) {
|
||||
console.log(page.props.auth.permissions);
|
||||
if (Array.isArray(page.props.auth.permissions)) {
|
||||
return page.props.auth.permissions.includes(permission);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const emptyTimeEntry = {
|
||||
project_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
organization_id: '',
|
||||
} as TimeEntry;
|
||||
|
||||
export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
@@ -56,20 +57,10 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
async function fetchCurrentTimeEntry() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
api.getTimeEntries({
|
||||
queries: {
|
||||
active: 'true',
|
||||
user_id: getCurrentUserId(),
|
||||
},
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
})
|
||||
);
|
||||
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
|
||||
if (timeEntriesResponse?.data) {
|
||||
if (timeEntriesResponse.data.length === 1) {
|
||||
currentTimeEntry.value = timeEntriesResponse.data[0];
|
||||
if (timeEntriesResponse.data) {
|
||||
currentTimeEntry.value = timeEntriesResponse.data;
|
||||
} else {
|
||||
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