Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
aee766e462 Add more validation for clockify importer 2025-04-29 16:29:57 +02:00
10 changed files with 42 additions and 298 deletions

View File

@@ -40,7 +40,6 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -49,7 +48,6 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),

View File

@@ -280,20 +280,6 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Description) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Billable) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === '0' ? 'Non-billable' : 'Billable',
'color' => null,
];
}
}
return $descriptorMap;

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useTheme } from "@/utils/theme.js";
import { onMounted, watch } from "vue";
import { theme } from "@/utils/theme.js";
onMounted(async () => {
useTheme()
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
});
</script>

View File

@@ -20,24 +20,24 @@ import {
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { onMounted, ref } from "vue";
import { onMounted, ref, watch } from "vue";
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores, refreshStores } from '@/utils/init';
import {
canManageBilling,
canUpdateOrganization,
canViewClients, canViewInvoices,
canViewClients,
canViewMembers,
canViewProjects, canViewReport,
canViewTags,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { isBillingActivated } from '@/utils/billing';
import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session';
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
import { useTheme } from "@/utils/theme";
import { theme } from "@/utils/theme";
defineProps({
title: String,
@@ -47,7 +47,12 @@ const showSidebarMenu = ref(false);
const isUnloading = ref(false);
onMounted(async () => {
useTheme()
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
if (window.initialDataLoaded !== true) {
window.initialDataLoaded = true;
@@ -183,7 +188,6 @@ const page = usePage<{
:current="route().current('tags')"
:href="route('tags')"></NavigationSidebarItem>
<NavigationSidebarItem
v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices"
:icon="DocumentTextIcon"
:current="route().current('invoices')"
@@ -268,6 +272,8 @@ const page = usePage<{
v-if="$slots.header"
class="bg-default-background border-b border-default-background-separator shadow">
<div class="pt-8 pb-3">
<MainContainer>
<slot name="header" />
</MainContainer>

View File

@@ -14,7 +14,6 @@ import { api } from '@/packages/api/src';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useReportingStore } from '@/utils/useReporting';
import { Head } from '@inertiajs/vue3';
import { useTheme } from "@/utils/theme";
const sharedSecret = ref<string | null>(null);
@@ -137,10 +136,6 @@ function getGroupLabel(key: string) {
return option.value === key;
})?.label;
}
onMounted(async () => {
useTheme();
})
</script>
<template>

View File

@@ -66,7 +66,6 @@ const InvoiceResource = z
buyer_name: z.string(),
status: z.string(),
date: z.string(),
due_at: z.string(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
})
@@ -107,8 +106,6 @@ const InvoiceStoreRequest = z
discount_type: InvoiceDiscountType.optional(),
footer: z.union([z.string(), z.null()]).optional(),
notes: z.union([z.string(), z.null()]).optional(),
payment_terms: z.union([z.string(), z.null()]).optional(),
is_eu_reverse_charge: z.boolean().optional(),
entries: z
.array(
z
@@ -130,7 +127,7 @@ const InvoiceEntryResource = z
name: z.string(),
description: z.union([z.string(), z.null()]),
unit_price: z.number().int(),
quantity: z.string(),
quantity: z.number().int(),
order_index: z.number().int(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
@@ -161,7 +158,7 @@ const DetailedInvoiceResource = z
buyer_address_country: z.string(),
buyer_phone: z.string(),
buyer_email: z.string(),
paid_at: z.union([z.string(), z.null()]),
paid_at: z.string(),
due_at: z.string(),
discount_type: z.string(),
discount_amount: z.string(),
@@ -171,8 +168,6 @@ const DetailedInvoiceResource = z
date: z.string(),
footer: z.string(),
notes: z.string(),
payment_terms: z.string(),
is_eu_reverse_charge: z.string(),
billing_period_start: z.string(),
billing_period_end: z.string(),
created_at: z.union([z.string(), z.null()]),
@@ -216,8 +211,6 @@ const InvoiceUpdateRequest = z
discount_type: InvoiceDiscountType,
footer: z.union([z.string(), z.null()]),
notes: z.union([z.string(), z.null()]),
payment_terms: z.union([z.string(), z.null()]),
is_eu_reverse_charge: z.boolean(),
entries: z.array(
z
.object({
@@ -232,9 +225,6 @@ const InvoiceUpdateRequest = z
})
.partial()
.passthrough();
const InvoiceDownloadRequest = z
.object({ with_e_invoice: z.boolean() })
.passthrough();
const InvoiceSettingResource = z
.object({
seller_name: z.union([z.string(), z.null()]),
@@ -472,9 +462,9 @@ const ReportStoreRequest = z
task_ids: z
.union([z.array(z.string().uuid()), z.null()])
.optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
history_group: TimeEntryAggregationTypeInterval,
group: TimeEntryAggregationType.optional(),
sub_group: TimeEntryAggregationType.optional(),
history_group: TimeEntryAggregationTypeInterval.optional(),
week_start: Weekday.optional(),
timezone: z.union([z.string(), z.null()]).optional(),
})
@@ -762,7 +752,6 @@ export const schemas = {
DetailedInvoiceResource,
InvoiceStatus,
InvoiceUpdateRequest,
InvoiceDownloadRequest,
InvoiceSettingResource,
InvoiceSettingUpdateRequest,
MemberResource,
@@ -1391,7 +1380,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -1676,7 +1665,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -1733,7 +1722,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -1769,7 +1758,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -2061,7 +2050,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -2086,11 +2075,6 @@ const endpoints = makeApi([
alias: 'downloadInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ with_e_invoice: z.boolean() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
@@ -2119,16 +2103,6 @@ const endpoints = makeApi([
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
@@ -2192,7 +2166,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2384,7 +2358,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2431,7 +2405,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2478,7 +2452,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -2576,7 +2550,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -2828,7 +2802,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -3201,7 +3175,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -3369,7 +3343,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -3596,7 +3570,7 @@ const endpoints = makeApi([
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -3976,7 +3950,7 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 401,
@@ -4591,7 +4565,7 @@ Please note that the access token is only shown in this response and cannot be r
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -4633,7 +4607,7 @@ Please note that the access token is only shown in this response and cannot be r
schema: z.string(),
},
],
response: z.void(),
response: z.null(),
errors: [
{
status: 400,
@@ -4698,11 +4672,6 @@ Please note that the access token is only shown in this response and cannot be r
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,

View File

@@ -9,14 +9,6 @@ export function isBillingActivated() {
return page.props.has_billing_extension;
}
export function isInvoicingActivated() {
const page = usePage<{
has_invoicing_extension: boolean;
}>();
return page.props.has_invoicing_extension;
}
export function isInTrial() {
const page = usePage<{
billing: {

View File

@@ -122,7 +122,3 @@ export function canDeleteReport() {
export function canViewAllTimeEntries() {
return currentUserHasPermission('time-entries:view:all');
}
export function canViewInvoices() {
return currentUserHasPermission('invoices:view');
}

View File

@@ -22,12 +22,4 @@ const theme = computed(() => {
return themeSetting.value
});
function useTheme() {
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
}
export { type themeOption, themeSetting, theme, useTheme };
export { type themeOption, themeSetting, theme };

View File

@@ -498,198 +498,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregated_time_entries_with_descriptions_by_description_and_billable(): void
{
// Arrange
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => 'TEST 1',
'billable' => true,
]);
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => '',
'billable' => false,
]);
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => 'TEST 1',
'billable' => false,
]);
TimeEntry::factory()->startWithDuration(now(), 10)->create([
'description' => '',
'billable' => false,
]);
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntriesWithDescriptions(
$query,
TimeEntryAggregationType::Description,
TimeEntryAggregationType::Billable,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true
);
// Assert
$this->assertSame([
'seconds' => 40,
'cost' => 0,
'grouped_type' => 'description',
'grouped_data' => [
[
'key' => null,
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'billable',
'grouped_data' => [
[
'key' => '0',
'seconds' => 20,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => 'Non-billable',
'color' => null,
],
],
'description' => null,
'color' => null,
],
[
'key' => 'TEST 1',
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'billable',
'grouped_data' => [
[
'key' => '0',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => 'Non-billable',
'color' => null,
],
[
'key' => '1',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => 'Billable',
'color' => null,
],
],
'description' => 'TEST 1',
'color' => null,
],
],
], $result);
}
public function test_aggregated_time_entries_with_descriptions_by_client_and_project(): void
{
// Arrange
$client1 = Client::factory()->create();
$client2 = Client::factory()->create();
$project1 = Project::factory()->forClient($client1)->create();
$project2 = Project::factory()->forClient($client2)->create();
$project3 = Project::factory()->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntriesWithDescriptions(
$query,
TimeEntryAggregationType::Client,
TimeEntryAggregationType::Project,
'Europe/Vienna',
Weekday::Monday,
false,
null,
null,
true
);
// Assert
$this->assertEqualsCanonicalizing([
'seconds' => 40,
'cost' => 0,
'grouped_type' => 'client',
'grouped_data' => [
[
'key' => null,
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => null,
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
[
'key' => $project3->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $project3->name,
'color' => $project3->color,
],
],
'description' => null,
'color' => null,
],
[
'key' => $client1->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project1->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $project1->name,
'color' => $project1->color,
],
],
'description' => $client1->name,
'color' => null,
],
[
'key' => $client2->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $project2->name,
'color' => $project2->color,
],
],
'description' => $client2->name,
'color' => null,
],
],
], $result);
}
}