Compare commits

..

12 Commits

Author SHA1 Message Date
Gregor Vostrak
7b82bf660b prevent billable rate change modals from immediately sumbitting when pressing enter on the previous form 2025-04-23 14:24:36 +02:00
Gregor Vostrak
a0a8a7f772 fix escape handling in tagdropdown and timetrackerprojecttaskdropdown after changing to radix dropdowns 2025-04-23 14:11:15 +02:00
Constantin Graf
5c63a94857 Add composer dependency “league/iso3166” 2025-04-23 12:37:20 +02:00
Gregor Vostrak
e377e58c98 add invoicing extension to private build action 2025-04-22 20:29:30 +02:00
Gregor Vostrak
08e0118181 add accordion component and countries api route 2025-04-22 17:32:32 +02:00
Gregor Vostrak
730604987f fix timeentry checkboxes 2025-04-22 17:11:41 +02:00
Gregor Vostrak
80523cba3a update api client, and report empty state improvement 2025-04-16 17:18:51 +02:00
Gregor Vostrak
af374c9c4d fix tests, add autofocus disable option for dropdown 2025-04-15 15:18:02 +02:00
Constantin Graf
48be348c4c Add composer package korridor/laravel-has-many-sync 2025-04-14 16:03:12 +02:00
Constantin Graf
7e2d1ccc3d Fixes for invoice feature 2025-04-13 23:37:57 +02:00
Gregor Vostrak
132b6cbe8f refactor to shadcn components, dynamically load extension frontend
add jetstream permissions, add dynamic inertia module loading, add shadcn components, change modals and dropdowns to shadcn dismissable layer,
2025-04-13 23:06:58 +02:00
Constantin Graf
4605aa75ff Add localization settings 2025-04-13 16:26:31 +02:00
43 changed files with 877 additions and 1528 deletions

View File

@@ -107,7 +107,7 @@ jobs:
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
- name: "Checkout services extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing

View File

@@ -63,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.2
uses: codecov/codecov-action@v5.4.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -13,7 +13,7 @@ use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource
{
@@ -38,8 +38,8 @@ class AuditResource extends Resource
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
PrettyJson::make('old_values'),
PrettyJson::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')

View File

@@ -20,7 +20,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
use Novadaemon\FilamentPrettyJson\PrettyJson;
/**
* @source https://gitlab.com/amvisor/filament-failed-jobs
@@ -50,7 +50,7 @@ class FailedJobResource extends Resource
// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
PrettyJson::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}

View File

@@ -18,7 +18,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class ReportResource extends Resource
{
@@ -58,7 +58,7 @@ class ReportResource extends Resource
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJsonField::make('properties')
PrettyJson::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties');
})

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

@@ -11,7 +11,6 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -28,7 +27,6 @@ class ProjectStoreRequest extends FormRequest
public function rules(): array
{
return [
// Name of the project, the name needs to be unique per client and organization
'name' => [
'required',
'string',
@@ -36,13 +34,7 @@ class ProjectStoreRequest extends FormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$clientId = $this->input('client_id');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.project_name_already_exists'),
],
'color' => [
@@ -63,7 +55,6 @@ class ProjectStoreRequest extends FormRequest
],
// ID of the client
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -11,7 +11,6 @@ use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -35,13 +34,7 @@ class ProjectUpdateRequest extends FormRequest
'max:255',
UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$clientId = $this->input('client_id');
if (! is_string($clientId) || ! Str::isUuid($clientId)) {
$clientId = null;
}
return $builder->whereBelongsTo($this->organization, 'organization')
->where('client_id', $clientId);
return $builder->whereBelongsTo($this->organization, 'organization');
})->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
],
'color' => [
@@ -61,7 +54,6 @@ class ProjectUpdateRequest extends FormRequest
'boolean',
],
'client_id' => [
'present',
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */

View File

@@ -40,7 +40,7 @@ class ReportStoreRequest extends FormRequest
'required',
'boolean',
],
// After this date the report will be automatically set to private (is_public=false) (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -59,12 +59,12 @@ class TimeEntryStoreRequest extends FormRequest
->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
],
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// Start of time entry (ISO 8601 format, UTC timezone)
'start' => [
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -59,11 +59,11 @@ class TimeEntryUpdateRequest extends FormRequest
->where('project_id', $this->input('project_id'));
})->uuid()->withMessage(__('validation.task_belongs_to_project')),
],
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// Start of time entry (ISO 8601 format, UTC timezone)
'start' => [
'date_format:Y-m-d\TH:i:s\Z',
],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
// End of time entry (ISO 8601 format, UTC timezone)
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',

View File

@@ -12,10 +12,6 @@ abstract class BaseResource extends JsonResource
protected function formatDateTime(?Carbon $carbon): ?string
{
return $carbon?->toIso8601ZuluString();
}
protected function formatDate(?Carbon $carbon): ?string
{
return $carbon?->format('Y-m-d');
}
}

View File

@@ -37,9 +37,9 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,

View File

@@ -83,9 +83,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => false,
]);
@@ -124,59 +124,34 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->is_imported = true;
// Start
$start = null;
try {
$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);
}
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);
}
} catch (InvalidFormatException) {
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
if ($start === null) {
throw new ImportException('Start date ("'.$startDateStr.'") or time ("'.$startTimeStr.'") are invalid');
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
$timeEntry->start = $start->utc();
// End
$end = null;
try {
$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);
}
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);
}
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,

View File

@@ -97,7 +97,7 @@ abstract class DefaultImporter implements ImporterContract
'in:placeholder',
],
]);
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id);
}, validate: [
@@ -114,11 +114,6 @@ abstract class DefaultImporter implements ImporterContract
'integer',
'max:2147483647',
],
'client_id' => [
'nullable',
'string',
'uuid',
],
], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) {
$project->billable_rate = null;

View File

@@ -55,12 +55,12 @@ class GenericProjectsImporter extends DefaultImporter
}
$this->projectImportHelper->getKey([
'name' => $record['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
'archived_at' => $archivedAt,

View File

@@ -99,9 +99,9 @@ class GenericTimeEntriesImporter extends DefaultImporter
if ($record['project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);

View File

@@ -60,10 +60,10 @@ class HarvestProjectsImporter extends DefaultImporter
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
$this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'color' => $this->colorService->getRandomColor(),
'client_id' => $clientId,
'estimated_time' => $estimatedTime,
'is_billable' => $billableHours > 0,
]);

View File

@@ -78,9 +78,9 @@ class HarvestTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => true,
]);

View File

@@ -176,12 +176,12 @@ class SolidtimeImporter extends DefaultImporter
$this->projectImportHelper->getKey([
'name' => $project['name'],
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(),
], [
'color' => $project['color'],
'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'],
'is_public' => $project['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => $project['is_billable'] === 'true',
'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null,
], $project['id']);

View File

@@ -137,9 +137,9 @@ class TogglDataImporter extends DefaultImporter
$projectId = $this->projectImportHelper->getKey([
'name' => $project->name,
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(),
], [
'client_id' => $clientId,
'color' => $project->color,
'is_billable' => $project->billable,
'is_public' => ! $project->is_private,

View File

@@ -83,9 +83,9 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);

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;

1671
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->bigInteger('spent_time')->unsigned()->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
Schema::table('tasks', function (Blueprint $table): void {
$table->integer('spent_time')->unsigned()->default(0)->change();
});
}
};

View File

@@ -202,7 +202,7 @@ return [
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
'project_name_already_exists' => 'A project with the same name already exists in the organization.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.',

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

@@ -339,8 +339,6 @@ const tableData = computed(() => {
@submit="updateReporting">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>

View File

@@ -308,8 +308,6 @@ async function downloadExport(format: ExportFormat) {
@submit="updateFilteredTimeEntries">
<template #trigger>
<ReportingFilterBadge
:count="selectedClients.length"
:active="selectedClients.length > 0"
title="Clients"
:icon="FolderIcon"></ReportingFilterBadge>
</template>

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

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { watch } from "vue";
const props = withDefaults(
defineProps<{
@@ -37,12 +36,6 @@ function onOpenChange(value: boolean) {
}
}
watch(open, (value) => {
if (value === false) {
emit('submit');
}
});
</script>
<template>

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

@@ -1,2 +0,0 @@
"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"
1 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
2 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

View File

@@ -277,7 +277,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -300,7 +299,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'billable_rate' => $billableRate,
]);
@@ -311,7 +309,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'billable_rate' => $billableRate,
]);
}
@@ -331,7 +328,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'billable_rate' => $billableRate,
]);
@@ -355,7 +351,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -365,7 +360,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
}
@@ -384,7 +378,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => 10000,
]);
@@ -401,7 +394,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => null,
]);
}
@@ -421,7 +413,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'name' => $projectFake->name,
'color' => $projectFake->color,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => 10000,
]);
@@ -438,47 +429,11 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'color' => $projectFake->color,
'organization_id' => $projectFake->organization_id,
'is_billable' => $projectFake->is_billable,
'client_id' => null,
'estimated_time' => 10000,
]);
}
public function test_store_endpoint_can_create_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$name = 'Project Name';
$clientA = Client::factory()->forOrganization($data->organization)->create();
$clientB = Client::factory()->forOrganization($data->organization)->create();
$projectA = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create([
'name' => $name,
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => $clientB->getKey(),
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Project::class, [
'name' => $name,
'client_id' => $clientB->getKey(),
]);
$this->assertDatabaseHas(Project::class, [
'name' => $name,
'client_id' => $clientA->getKey(),
]);
}
public function test_store_endpoint_fails_without_client_if_name_is_already_used_for_project_without_client_in_organization(): void
public function test_store_endpoint_fails_if_name_is_already_used_in_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
@@ -495,43 +450,13 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'name' => 'A project with the same name and client already exists in the organization.',
]);
}
public function test_store_endpoint_fails_with_client_if_name_is_already_used_for_the_same_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$name = 'Project Name';
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create([
'name' => $name,
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => $client->getKey(),
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'name' => 'A project with the same name and client already exists in the organization.',
'name' => 'A project with the same name already exists in the organization.',
]);
}
@@ -553,7 +478,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -610,7 +534,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10001,
]);
@@ -642,7 +565,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -663,7 +585,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -671,43 +592,7 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$name = 'Project Name';
$clientA = Client::factory()->forOrganization($data->organization)->create();
$clientB = Client::factory()->forOrganization($data->organization)->create();
$projectWithTheName = Project::factory()->forOrganization($data->organization)->forClient($clientA)->create([
'name' => $name,
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$projectFake = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => $clientB->getKey(),
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Project::class, [
'name' => $name,
'client_id' => $clientA->getKey(),
]);
$this->assertDatabaseHas(Project::class, [
'name' => $name,
'client_id' => $clientB->getKey(),
]);
}
public function test_update_endpoint_fails_without_client_if_name_is_already_used_for_project_without_client_in_organization(): void
public function test_update_endpoint_fails_if_name_is_already_used_in_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
@@ -725,44 +610,13 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'name' => 'A project with the same name and client already exists in the organization.',
]);
}
public function test_update_endpoint_fails_with_client_if_name_is_already_used_for_the_same_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$name = 'Project Name';
$client = Client::factory()->forOrganization($data->organization)->create();
$projectWithTheName = Project::factory()->forOrganization($data->organization)->forClient($client)->create([
'name' => $name,
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$projectFake = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => $client->getKey(),
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'name' => 'A project with the same name and client already exists in the organization.',
'name' => 'A project with the same name already exists in the organization.',
]);
}
@@ -866,7 +720,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
@@ -929,7 +782,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'estimated_time' => 10000,
]);
@@ -963,7 +815,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'estimated_time' => 10000,
]);
@@ -997,7 +848,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'billable_rate' => $project->billable_rate,
]);
@@ -1030,7 +880,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'billable_rate' => 10003,
]);
@@ -1058,7 +907,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_archived' => true,
]);
@@ -1087,7 +935,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_archived' => false,
]);

View File

@@ -16,13 +16,11 @@ class ForceHttpsMiddlewareTest extends MiddlewareTestAbstract
{
private function createTestRoute(): string
{
$uri = Route::get('/test-route', function () {
return Route::get('/test-route', function () {
return [
'is_secure' => request()->secure(),
];
})->middleware(ForceHttps::class)->uri;
return url($uri, [], false);
}
public function test_if_config_app_force_https_is_true_then_the_request_will_be_modified_to_make_the_app_think_it_was_a_https_request(): void

View File

@@ -185,19 +185,4 @@ class ProjectModelTest extends ModelTestAbstract
// Assert
$this->assertFalse($isArchived);
}
public function test_project_can_store_big_amounts_of_spent_time(): void
{
// Arrange
$project = Project::factory()->create();
$spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds
// Act
$project->spent_time = $spentTime;
$project->save();
$project->refresh();
// Assert
$this->assertSame($spentTime, $project->spent_time);
}
}

View File

@@ -114,19 +114,4 @@ class TaskModelTest extends ModelTestAbstract
// Assert
$this->assertFalse($task->is_done);
}
public function test_task_can_store_big_amounts_of_spent_time(): void
{
// Arrange
$task = Task::factory()->create();
$spentTime = 100 * 365 * 24 * 60 * 60; // 100 years in seconds
// Act
$task->spent_time = $spentTime;
$task->save();
$task->refresh();
// Assert
$this->assertSame($spentTime, $task->spent_time);
}
}

View File

@@ -94,25 +94,4 @@ 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();
}
}

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);
}
}