mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
5 Commits
feature/mo
...
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8110e222a | ||
|
|
7673b365ca | ||
|
|
da5fc3f113 | ||
|
|
8c66068663 | ||
|
|
dd0cc0d60b |
@@ -40,6 +40,7 @@ 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);
|
||||
@@ -48,6 +49,7 @@ 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),
|
||||
|
||||
@@ -124,34 +124,59 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
// Start
|
||||
$start = null;
|
||||
try {
|
||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
|
||||
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], $timezone);
|
||||
} else {
|
||||
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], $timezone);
|
||||
$startDateStr = $record['Start Date'];
|
||||
$startTimeStr = $record['Start Time'];
|
||||
$startStr = $startDateStr.' '.$startTimeStr;
|
||||
$matches = [];
|
||||
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $startStr, $matches);
|
||||
|
||||
if ($checkResult === 1) {
|
||||
if ((int) $matches[1] > 12) {
|
||||
throw new ImportException('Start date ("'.$startDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
|
||||
}
|
||||
if ($matches[6] === '') {
|
||||
$start = Carbon::createFromFormat('m/d/Y h:i A', $startStr, $timezone);
|
||||
} else {
|
||||
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $startStr, $timezone);
|
||||
}
|
||||
}
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
||||
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
|
||||
}
|
||||
if ($start === null) {
|
||||
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
|
||||
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
|
||||
}
|
||||
$timeEntry->start = $start->utc();
|
||||
|
||||
// End
|
||||
$end = null;
|
||||
try {
|
||||
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
|
||||
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], $timezone);
|
||||
} else {
|
||||
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], $timezone);
|
||||
$endDateStr = $record['End Date'];
|
||||
$endTimeStr = $record['End Time'];
|
||||
$endStr = $endDateStr.' '.$endTimeStr;
|
||||
$matches = [];
|
||||
$checkResult = preg_match('/^([0-9]{1,2})\/([0-9]{1,2})\/([0-9]{4}) ([0-9]{1,2}):([0-9]{1,2})(:[0-9]{1,2})? (AM|PM)$/', $endStr, $matches);
|
||||
|
||||
if ($checkResult === 1) {
|
||||
if ((int) $matches[1] > 12) {
|
||||
throw new ImportException('Start date ("'.$endDateStr.'") is invalid, please select the correct date format before exporting from Clockify');
|
||||
}
|
||||
if ($matches[6] === '') {
|
||||
$end = Carbon::createFromFormat('m/d/Y h:i A', $endStr, $timezone);
|
||||
} else {
|
||||
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $endStr, $timezone);
|
||||
}
|
||||
}
|
||||
} catch (InvalidFormatException) {
|
||||
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
||||
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||
}
|
||||
if ($end === null) {
|
||||
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
|
||||
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
|
||||
}
|
||||
$timeEntry->end = $end->utc();
|
||||
|
||||
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
|
||||
$timeEntry,
|
||||
$projectMember,
|
||||
|
||||
@@ -280,6 +280,20 @@ 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;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from "vue";
|
||||
import { theme } from "@/utils/theme.js";
|
||||
import { onMounted } from "vue";
|
||||
import { useTheme } from "@/utils/theme.js";
|
||||
|
||||
onMounted(async () => {
|
||||
document.documentElement.classList.add(theme.value);
|
||||
watch(theme, (newTheme, oldTheme) => {
|
||||
document.documentElement.classList.remove(oldTheme);
|
||||
document.documentElement.classList.add(newTheme);
|
||||
});
|
||||
useTheme()
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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, watch } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||
import { initializeStores, refreshStores } from '@/utils/init';
|
||||
import {
|
||||
canManageBilling,
|
||||
canUpdateOrganization,
|
||||
canViewClients,
|
||||
canViewClients, canViewInvoices,
|
||||
canViewMembers,
|
||||
canViewProjects, canViewReport,
|
||||
canViewTags,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated } from '@/utils/billing';
|
||||
import { isBillingActivated, isInvoicingActivated } 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 { theme } from "@/utils/theme";
|
||||
import { useTheme } from "@/utils/theme";
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -47,12 +47,7 @@ const showSidebarMenu = ref(false);
|
||||
const isUnloading = ref(false);
|
||||
onMounted(async () => {
|
||||
|
||||
document.documentElement.classList.add(theme.value);
|
||||
watch(theme, (newTheme, oldTheme) => {
|
||||
document.documentElement.classList.remove(oldTheme);
|
||||
document.documentElement.classList.add(newTheme);
|
||||
});
|
||||
|
||||
useTheme()
|
||||
// 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;
|
||||
@@ -188,6 +183,7 @@ const page = usePage<{
|
||||
:current="route().current('tags')"
|
||||
:href="route('tags')"></NavigationSidebarItem>
|
||||
<NavigationSidebarItem
|
||||
v-if="isInvoicingActivated() && canViewInvoices()"
|
||||
title="Invoices"
|
||||
:icon="DocumentTextIcon"
|
||||
:current="route().current('invoices')"
|
||||
@@ -272,8 +268,6 @@ 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>
|
||||
|
||||
@@ -14,6 +14,7 @@ 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);
|
||||
|
||||
@@ -136,6 +137,10 @@ function getGroupLabel(key: string) {
|
||||
return option.value === key;
|
||||
})?.label;
|
||||
}
|
||||
onMounted(async () => {
|
||||
useTheme();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -66,6 +66,7 @@ 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()]),
|
||||
})
|
||||
@@ -106,6 +107,8 @@ 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
|
||||
@@ -127,7 +130,7 @@ const InvoiceEntryResource = z
|
||||
name: z.string(),
|
||||
description: z.union([z.string(), z.null()]),
|
||||
unit_price: z.number().int(),
|
||||
quantity: z.number().int(),
|
||||
quantity: z.string(),
|
||||
order_index: z.number().int(),
|
||||
created_at: z.union([z.string(), z.null()]),
|
||||
updated_at: z.union([z.string(), z.null()]),
|
||||
@@ -158,7 +161,7 @@ const DetailedInvoiceResource = z
|
||||
buyer_address_country: z.string(),
|
||||
buyer_phone: z.string(),
|
||||
buyer_email: z.string(),
|
||||
paid_at: z.string(),
|
||||
paid_at: z.union([z.string(), z.null()]),
|
||||
due_at: z.string(),
|
||||
discount_type: z.string(),
|
||||
discount_amount: z.string(),
|
||||
@@ -168,6 +171,8 @@ 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()]),
|
||||
@@ -211,6 +216,8 @@ 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({
|
||||
@@ -225,6 +232,9 @@ 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()]),
|
||||
@@ -462,9 +472,9 @@ const ReportStoreRequest = z
|
||||
task_ids: z
|
||||
.union([z.array(z.string().uuid()), z.null()])
|
||||
.optional(),
|
||||
group: TimeEntryAggregationType.optional(),
|
||||
sub_group: TimeEntryAggregationType.optional(),
|
||||
history_group: TimeEntryAggregationTypeInterval.optional(),
|
||||
group: TimeEntryAggregationType,
|
||||
sub_group: TimeEntryAggregationType,
|
||||
history_group: TimeEntryAggregationTypeInterval,
|
||||
week_start: Weekday.optional(),
|
||||
timezone: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
@@ -752,6 +762,7 @@ export const schemas = {
|
||||
DetailedInvoiceResource,
|
||||
InvoiceStatus,
|
||||
InvoiceUpdateRequest,
|
||||
InvoiceDownloadRequest,
|
||||
InvoiceSettingResource,
|
||||
InvoiceSettingUpdateRequest,
|
||||
MemberResource,
|
||||
@@ -1380,7 +1391,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -1665,7 +1676,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -1722,7 +1733,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
@@ -1758,7 +1769,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
@@ -2050,7 +2061,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
@@ -2075,6 +2086,11 @@ 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',
|
||||
@@ -2103,6 +2119,16 @@ 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(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2166,7 +2192,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -2358,7 +2384,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -2405,7 +2431,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -2452,7 +2478,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -2550,7 +2576,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
@@ -2802,7 +2828,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -3175,7 +3201,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
@@ -3343,7 +3369,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -3570,7 +3596,7 @@ const endpoints = makeApi([
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -3950,7 +3976,7 @@ Users with the permission `time-entries:view:own` can only use this en
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 401,
|
||||
@@ -4565,7 +4591,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -4607,7 +4633,7 @@ Please note that the access token is only shown in this response and cannot be r
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
@@ -4672,6 +4698,11 @@ 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`,
|
||||
|
||||
@@ -9,6 +9,14 @@ 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: {
|
||||
|
||||
@@ -122,3 +122,7 @@ export function canDeleteReport() {
|
||||
export function canViewAllTimeEntries() {
|
||||
return currentUserHasPermission('time-entries:view:all');
|
||||
}
|
||||
export function canViewInvoices() {
|
||||
return currentUserHasPermission('invoices:view');
|
||||
}
|
||||
|
||||
|
||||
@@ -22,4 +22,12 @@ const theme = computed(() => {
|
||||
return themeSetting.value
|
||||
});
|
||||
|
||||
export { type themeOption, themeSetting, theme };
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"Project","Client","Description","Task","User","Group","Email","Tags","Type","Billable","Invoiced","Invoice ID","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (EUR)","Billable Amount (EUR)","Date of creation"
|
||||
"Real World Project","Real World Client","\\ 🔥 Special characters ''''''`!@#$%^&*()_+\-=\[\]{};':''\\|,.''<>\/?~ \\\","A giant task","Peter Tester","Group1, Group2","peter.test@email.test","","Regular","Yes","Yes","Invoice100","13/15/2024","11:00:00 AM","10/15/2024","11:30:00 AM","00:30:00","0.50","1000.00","500.00","10/15/2024"
|
||||
|
@@ -94,4 +94,25 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
||||
$this->assertSame(0, $report->projectsCreated);
|
||||
$this->assertSame(0, $report->clientsCreated);
|
||||
}
|
||||
|
||||
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$timezone = 'Europe/Vienna';
|
||||
$importer = new ClockifyTimeEntriesImporter;
|
||||
$importer->init($organization);
|
||||
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_3.csv');
|
||||
|
||||
// Act
|
||||
try {
|
||||
$importer->importData($data, $timezone);
|
||||
} catch (ImportException $e) {
|
||||
// Assert
|
||||
$this->assertSame('Start date ("13/15/2024") is invalid, please select the correct date format before exporting from Clockify', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
$this->fail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,4 +498,198 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user