Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
b802cd4a65 Bump the minor-updates group across 1 directory with 12 updates
Bumps the minor-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [brick/money](https://github.com/brick/money) | `0.15.1` | `0.15.2` |
| [dedoc/scramble](https://github.com/dedoc/scramble) | `0.13.45` | `0.13.47` |
| [filament/filament](https://github.com/filamentphp/panels) | `5.8.4` | `5.10.1` |
| [inertiajs/inertia-laravel](https://github.com/inertiajs/inertia-laravel) | `3.3.4` | `3.5.1` |
| [laravel/fortify](https://github.com/laravel/fortify) | `1.40.0` | `1.41.0` |
| [laravel/framework](https://github.com/laravel/framework) | `13.33.0` | `13.35.0` |
| [laravel/octane](https://github.com/laravel/octane) | `2.20.0` | `2.21.0` |
| [laravel/passport](https://github.com/laravel/passport) | `13.8.0` | `13.9.0` |
| [stechstudio/filament-impersonate](https://github.com/stechstudio/filament-impersonate) | `5.6.0` | `5.6.1` |
| [tpetry/laravel-postgresql-enhanced](https://github.com/tpetry/laravel-postgresql-enhanced) | `3.7.0` | `3.8.0` |
| [fumeapp/modeltyper](https://github.com/fumeapp/modeltyper) | `3.13.0` | `3.14.0` |
| [larastan/larastan](https://github.com/larastan/larastan) | `3.12.2` | `3.13.0` |



Updates `brick/money` from 0.15.1 to 0.15.2
- [Release notes](https://github.com/brick/money/releases)
- [Changelog](https://github.com/brick/money/blob/main/CHANGELOG.md)
- [Commits](https://github.com/brick/money/compare/0.15.1...0.15.2)

Updates `dedoc/scramble` from 0.13.45 to 0.13.47
- [Release notes](https://github.com/dedoc/scramble/releases)
- [Commits](https://github.com/dedoc/scramble/compare/v0.13.45...v0.13.47)

Updates `filament/filament` from 5.8.4 to 5.10.1
- [Commits](https://github.com/filamentphp/panels/compare/v5.8.4...v5.10.1)

Updates `inertiajs/inertia-laravel` from 3.3.4 to 3.5.1
- [Release notes](https://github.com/inertiajs/inertia-laravel/releases)
- [Changelog](https://github.com/inertiajs/inertia-laravel/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia-laravel/compare/v3.3.4...v3.5.1)

Updates `laravel/fortify` from 1.40.0 to 1.41.0
- [Release notes](https://github.com/laravel/fortify/releases)
- [Changelog](https://github.com/laravel/fortify/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/fortify/compare/v1.40.0...v1.41.0)

Updates `laravel/framework` from 13.33.0 to 13.35.0
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v13.33.0...v13.35.0)

Updates `laravel/octane` from 2.20.0 to 2.21.0
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.20.0...v2.21.0)

Updates `laravel/passport` from 13.8.0 to 13.9.0
- [Release notes](https://github.com/laravel/passport/releases)
- [Changelog](https://github.com/laravel/passport/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/passport/compare/v13.8.0...v13.9.0)

Updates `stechstudio/filament-impersonate` from 5.6.0 to 5.6.1
- [Release notes](https://github.com/stechstudio/filament-impersonate/releases)
- [Commits](https://github.com/stechstudio/filament-impersonate/compare/v5.6.0...v5.6.1)

Updates `tpetry/laravel-postgresql-enhanced` from 3.7.0 to 3.8.0
- [Changelog](https://github.com/tpetry/laravel-postgresql-enhanced/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tpetry/laravel-postgresql-enhanced/compare/3.7.0...3.8.0)

Updates `fumeapp/modeltyper` from 3.13.0 to 3.14.0
- [Release notes](https://github.com/fumeapp/modeltyper/releases)
- [Commits](https://github.com/fumeapp/modeltyper/compare/v3.13.0...v3.14.0)

Updates `larastan/larastan` from 3.12.2 to 3.13.0
- [Release notes](https://github.com/larastan/larastan/releases)
- [Changelog](https://github.com/larastan/larastan/blob/3.x/RELEASE.md)
- [Commits](https://github.com/larastan/larastan/compare/v3.12.2...v3.13.0)

---
updated-dependencies:
- dependency-name: brick/money
  dependency-version: 0.15.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: dedoc/scramble
  dependency-version: 0.13.47
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: filament/filament
  dependency-version: 5.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: inertiajs/inertia-laravel
  dependency-version: 3.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/fortify
  dependency-version: 1.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 13.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/passport
  dependency-version: 13.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: stechstudio/filament-impersonate
  dependency-version: 5.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: tpetry/laravel-postgresql-enhanced
  dependency-version: 3.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: fumeapp/modeltyper
  dependency-version: 3.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: larastan/larastan
  dependency-version: 3.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 23:31:40 +00:00
149 changed files with 832 additions and 4261 deletions

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
class CorrectionDeleteTimeEntriesWithEndBeforeStartCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:delete-time-entries-with-end-before-start '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Deletes all time entries where the end is before the start';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Deletes all time entries where the end is before the start...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$timeEntries = TimeEntry::query()
->whereNotNull('end')
->whereColumn('end', '<', 'start')
->with(['project', 'task'])
->get();
foreach ($timeEntries as $timeEntry) {
/** @var TimeEntry $timeEntry */
if (! $dryRun) {
// Delete via Eloquent, so that the deletion is audited
$timeEntry->delete();
if ($timeEntry->project !== null) {
RecalculateSpentTimeForProject::dispatch($timeEntry->project);
}
if ($timeEntry->task !== null) {
RecalculateSpentTimeForTask::dispatch($timeEntry->task);
}
}
$this->line('Deleted time entry (id='.$timeEntry->getKey().', start='.$timeEntry->start->toIso8601ZuluString().', end='.$timeEntry->end?->toIso8601ZuluString().')');
}
return self::SUCCESS;
}
}

View File

@@ -39,7 +39,7 @@ class SelfHostDatabaseConsistency extends Command
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->whereRaw('tasks.project_id is distinct from time_entries.project_id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
@@ -59,14 +59,6 @@ class SelfHostDatabaseConsistency extends Command
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// End of time entries can not be before the start
$problems = DB::table('time_entries')
->select(['id'])
->whereNotNull('end')
->whereColumn('end', '<', 'start')
->get();
$this->logProblems($problems, 'Time entries have an end that is before the start', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class TimeEntryMemberCanNotBeChangedWhileRunningApiException extends ApiException
{
public const string KEY = 'time_entry_member_can_not_be_changed_while_running';
public function report(): bool
{
return true;
}
}

View File

@@ -183,7 +183,7 @@ class OrganizationResource extends Resource
->action(function (Organization $record, array $data): void {
try {
try {
$file = Storage::disk(config('filesystems.private'))->get($data['file']);
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
} catch (UnableToReadFile) {
$file = null;
}
@@ -220,17 +220,12 @@ class OrganizationResource extends Resource
->body('Message: '.$exception->getMessage())
->persistent()
->send();
} finally {
Storage::disk(config('filesystems.private'))->delete($data['file']);
}
})
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->schema([
FileUpload::make('file')
->label('File')
->disk(config('filesystems.private'))
->directory('admin-import-uploads')
->visibility('private')
->required(),
Select::make('type')
->required()

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
@@ -154,12 +154,12 @@ class ChartController extends Controller
*
* @response array{value: int, currency: string}
*/
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService, BillableRateService $billableRateService): JsonResponse
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}

View File

@@ -49,9 +49,4 @@ class Controller extends \App\Http\Controllers\Controller
{
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
protected function canUseBillableRates(Organization $organization): bool
{
return app(BillingContract::class)->canUseBillableRates($organization);
}
}

View File

@@ -81,9 +81,10 @@ class MemberController extends Controller
{
$this->checkPermission($organization, 'members:update', $member);
$oldBillableRate = $member->billable_rate;
if ($request->has('billable_rate') && $this->canUseBillableRates($organization)) {
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
$member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole();
@@ -92,10 +93,6 @@ class MemberController extends Controller
}
$member->save();
if ($oldBillableRate !== $member->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
return new MemberResource($member);
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
@@ -27,11 +28,11 @@ class OrganizationController extends Controller
*
* @throws AuthorizationException
*/
public function show(Organization $organization, BillableRateService $billableRateService): OrganizationResource
public function show(Organization $organization): OrganizationResource
{
$this->checkPermission($organization, 'organizations:view');
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new OrganizationResource($organization, $showBillableRate);
}
@@ -46,7 +47,6 @@ class OrganizationController extends Controller
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
{
$this->checkPermission($organization, 'organizations:update');
$canUseBillableRates = $this->canUseBillableRates($organization);
if ($request->getName() !== null) {
$organization->name = $request->getName();
@@ -54,8 +54,7 @@ class OrganizationController extends Controller
if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency();
}
// Organizations that can not use billable rates can keep or remove the visibility for employees, but not enable it
if ($request->getEmployeesCanSeeBillableRates() !== null && ($canUseBillableRates || ! $request->getEmployeesCanSeeBillableRates())) {
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getEmployeesCanManageTasks() !== null) {
@@ -82,13 +81,14 @@ class OrganizationController extends Controller
if ($request->getBreaksEnabled() !== null) {
$organization->breaks_enabled = $request->getBreaksEnabled();
}
$oldBillableRate = $organization->billable_rate;
if ($request->has('billable_rate') && $canUseBillableRates) {
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;
$organization->billable_rate = $request->getBillableRate();
}
$organization->save();
if ($oldBillableRate !== $organization->billable_rate) {
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -38,7 +39,7 @@ class ProjectController extends Controller
*
* @operationId getProjects
*/
public function index(Organization $organization, ProjectIndexRequest $request, BillableRateService $billableRateService): ProjectCollection
public function index(Organization $organization, ProjectIndexRequest $request): ProjectCollection
{
$this->checkPermission($organization, 'projects:view');
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
@@ -62,7 +63,7 @@ class ProjectController extends Controller
->orderBy('id')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new ProjectCollection($projects, $showBillableRate);
}
@@ -96,12 +97,11 @@ class ProjectController extends Controller
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:create');
$canUseBillableRates = $this->canUseBillableRates($organization);
$project = new Project;
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->is_billable = $canUseBillableRates && $request->boolean('is_billable');
$project->billable_rate = $canUseBillableRates ? $request->getBillableRate() : null;
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->is_public = $request->getIsPublic();
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
@@ -123,13 +123,9 @@ class ProjectController extends Controller
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'projects:update', $project);
$canUseBillableRates = $this->canUseBillableRates($organization);
$project->name = $request->input('name');
$project->color = $request->input('color');
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
if ($canUseBillableRates || ! $request->boolean('is_billable')) {
$project->is_billable = $request->boolean('is_billable');
}
$project->is_billable = (bool) $request->input('is_billable');
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
@@ -141,16 +137,14 @@ class ProjectController extends Controller
}
$oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
if ($request->has('billable_rate') && $canUseBillableRates) {
$project->billable_rate = $request->getBillableRate();
}
$project->billable_rate = $request->getBillableRate();
if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id');
$clientIdChanged = true;
}
$project->save();
if ($oldBillableRate !== $project->billable_rate) {
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
if ($clientIdChanged) {

View File

@@ -73,13 +73,13 @@ class ProjectMemberController extends Controller
}
$projectMember = new ProjectMember;
$projectMember->billable_rate = $this->canUseBillableRates($organization) ? $request->getBillableRate() : null;
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->member()->associate($member);
$projectMember->user()->associate($member->user);
$projectMember->project()->associate($project);
$projectMember->save();
if ($projectMember->billable_rate !== null) {
if ($request->getBillableRate() !== null) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}
@@ -97,12 +97,10 @@ class ProjectMemberController extends Controller
{
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$oldBillableRate = $projectMember->billable_rate;
if ($this->canUseBillableRates($organization)) {
$projectMember->billable_rate = $request->getBillableRate();
}
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->save();
if ($oldBillableRate !== $projectMember->billable_rate) {
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}

View File

@@ -5,12 +5,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
@@ -29,7 +29,6 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
@@ -52,7 +51,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -229,7 +227,7 @@ class TimeEntryController extends Controller
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
@@ -247,7 +245,7 @@ class TimeEntryController extends Controller
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -386,7 +384,7 @@ class TimeEntryController extends Controller
*
* @throws AuthorizationException
*/
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): array
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
@@ -398,7 +396,7 @@ class TimeEntryController extends Controller
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
@@ -436,7 +434,7 @@ class TimeEntryController extends Controller
* @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
$member = $this->member($organization);
/** @var Member|null $memberFilter */
@@ -453,7 +451,7 @@ class TimeEntryController extends Controller
}
$debug = $request->getDebug();
$user = $this->user();
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
@@ -615,24 +613,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:create:all');
}
// Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry
$lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null;
$lock?->block(5);
try {
return $this->storeTimeEntry($organization, $member, $request);
} finally {
$lock?->release();
}
}
/**
* @throws TimeEntryStillRunningApiException
*/
private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource
{
// A user can only have one running time entry, across all organizations
if ($request->input('end') === null && TimeEntry::query()->where('user_id', $member->user_id)->whereNull('end')->exists()) {
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
throw new TimeEntryStillRunningApiException;
}
@@ -647,10 +628,6 @@ class TimeEntryController extends Controller
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
// Organizations that can not use billable rates can not create billable time entries
if ($timeEntry->billable && ! $this->canUseBillableRates($organization)) {
$timeEntry->billable = false;
}
$timeEntry->client()->associate($client);
$timeEntry->user_id = $member->user_id;
$timeEntry->description = $request->input('description') ?? '';
@@ -673,7 +650,7 @@ class TimeEntryController extends Controller
*
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
*
* @operationId updateTimeEntry
*/
@@ -699,11 +676,6 @@ class TimeEntryController extends Controller
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
// The new member could already have a running time entry
if ($effectiveEnd === null && $newMember !== null && $newMember->getKey() !== $timeEntry->member_id) {
throw new TimeEntryMemberCanNotBeChangedWhileRunningApiException;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
@@ -718,20 +690,11 @@ class TimeEntryController extends Controller
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
}
$wasBillable = $timeEntry->billable;
$timeEntry->fill($request->validated());
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
if ($timeEntry->billable && ! $wasBillable && ! $this->canUseBillableRates($organization)) {
$timeEntry->billable = false;
}
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user);
}
// If project is changed, but task is not, we remove the old task from the time entry
if ($request->has('project_id') && ! $request->has('task_id') && $oldTask !== null && $oldTask->project_id !== $project?->getKey()) {
$timeEntry->task()->disassociate();
}
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
@@ -781,7 +744,6 @@ class TimeEntryController extends Controller
->get();
$changes = $request->validated('changes');
$canUseBillableRates = $this->canUseBillableRates($organization);
if ($request->has('changes.description')) {
$changes['description'] = $request->input('changes.description') ?? '';
@@ -839,27 +801,16 @@ class TimeEntryController extends Controller
continue;
}
// The new member could already have a running time entry
if ($newMember !== null && $timeEntry->end === null && $newMember->getKey() !== $timeEntry->member_id) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$wasBillable = $timeEntry->billable;
$timeEntry->fill($changes);
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
if (! $wasBillable && ! $canUseBillableRates) {
$timeEntry->billable = false;
}
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id;
}
// If project is changed, but task is not, we remove the old task from the time entry
if ($request->has('changes.project_id') && ! $request->has('changes.task_id') && $oldTask !== null && $oldTask->project_id !== $project?->getKey()) {
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate();
}
if ($overwriteClient) {

View File

@@ -60,7 +60,6 @@ class HandleInertiaRequests extends Middleware
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization),
'goal_limit' => $billing->getGoalLimit($currentOrganization),
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),

View File

@@ -68,7 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency), the current rate is kept if the field is omitted
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([
'nullable',
],

View File

@@ -13,11 +13,9 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore;
use Carbon\Exceptions\InvalidFormatException;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule;
@@ -92,32 +90,12 @@ class TimeEntryUpdateRequest extends BaseFormRequest
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'start' => [
'date_format:Y-m-d\TH:i:s\Z',
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// If the payload does not contain an end, the start needs to be validated against the persisted end
if ($this->has('end') || $timeEntry?->end === null) {
return;
}
$start = $this->parseDate($value);
if ($start !== null && $start->gt($timeEntry->end)) {
$fail('The start field must be a date before or equal to end.');
}
},
],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start',
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// If the payload does not contain a start, the end needs to be validated against the persisted start
if ($this->has('start') || $timeEntry === null) {
return;
}
$end = $this->parseDate($value);
if ($end !== null && $end->lt($timeEntry->start)) {
$fail('The end field must be a date after or equal to start.');
}
},
],
// Whether time entry is billable
'billable' => [
@@ -159,16 +137,4 @@ class TimeEntryUpdateRequest extends BaseFormRequest
],
];
}
private function parseDate(mixed $value): ?Carbon
{
if (! is_string($value)) {
return null;
}
try {
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC');
} catch (InvalidFormatException) {
return null;
}
}
}

View File

@@ -11,7 +11,6 @@ use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Resources\V1\BaseResource;
use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\CurrencyService;
use Illuminate\Http\Request;
@@ -52,8 +51,6 @@ class OrganizationResource extends BaseResource
'is_personal' => $this->resource->personal_team,
/** Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Whether the organization's plan allows billable flags and rates */
'can_use_billable_rates' => app(BillingContract::class)->canUseBillableRates($this->resource),
/** Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -14,24 +13,6 @@ use Illuminate\Database\Eloquent\Builder;
class BillableRateService
{
public function __construct(
private BillingContract $billing,
) {}
public function canUseBillableRates(Organization $organization): bool
{
return $this->billing->canUseBillableRates($organization);
}
public function canSeeBillableRates(Organization $organization, Member $member): bool
{
if (! $this->canUseBillableRates($organization)) {
return false;
}
return $member->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
}
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
{
TimeEntry::query()

View File

@@ -63,13 +63,4 @@ class BillingContract
{
return false;
}
/**
* Check if the organization can set and see billable rates
* Without a billing system every organization can use billable rates
*/
public function canUseBillableRates(Organization $organization): bool
{
return true;
}
}

View File

@@ -116,13 +116,13 @@ class ReportPropertiesDto implements Castable
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
$dto->active = $data->active;
$dto->setMemberIds($data->memberIds !== null ? (array) $data->memberIds : null);
$dto->memberIds = $data->memberIds !== null ? TimeEntryFilter::idArrayToCollection($data->memberIds) : null;
$dto->billable = $data->billable;
$dto->setClientIds($data->clientIds !== null ? (array) $data->clientIds : null);
$dto->setProjectIds($data->projectIds !== null ? (array) $data->projectIds : null);
$dto->setTagIds($data->tagIds !== null ? (array) $data->tagIds : null);
$dto->clientIds = $data->clientIds !== null ? TimeEntryFilter::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? TimeEntryFilter::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? TimeEntryFilter::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->setTaskIds($data->taskIds !== null ? (array) $data->taskIds : null);
$dto->taskIds = $data->taskIds ? TimeEntryFilter::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
@@ -184,7 +184,7 @@ class ReportPropertiesDto implements Castable
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null && count($memberIds) > 0 ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
$this->memberIds = $memberIds !== null ? TimeEntryFilter::idArrayToCollection($memberIds) : null;
}
/**
@@ -192,7 +192,7 @@ class ReportPropertiesDto implements Castable
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null && count($clientIds) > 0 ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
$this->clientIds = $clientIds !== null ? TimeEntryFilter::idArrayToCollection($clientIds) : null;
}
/**
@@ -200,7 +200,7 @@ class ReportPropertiesDto implements Castable
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null && count($projectIds) > 0 ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
$this->projectIds = $projectIds !== null ? TimeEntryFilter::idArrayToCollection($projectIds) : null;
}
/**
@@ -208,7 +208,7 @@ class ReportPropertiesDto implements Castable
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null && count($tagIds) > 0 ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
$this->tagIds = $tagIds !== null ? TimeEntryFilter::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
@@ -221,6 +221,6 @@ class ReportPropertiesDto implements Castable
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null && count($taskIds) > 0 ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
$this->taskIds = $taskIds !== null ? TimeEntryFilter::idArrayToCollection($taskIds) : null;
}
}

View File

@@ -9,11 +9,8 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportService
{
@@ -25,8 +22,6 @@ class ImportService
/** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType);
$importer->init($organization);
Storage::disk(config('filesystems.private'))
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);

View File

@@ -128,7 +128,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
$timeEntry->billable = $record['Billable'] === 'Yes';
}
if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above)
@@ -190,9 +190,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
}
if ($end->lt($start)) {
throw new ImportException('End ("'.$endStr.'") is before start ("'.$startStr.'")');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(

View File

@@ -74,16 +74,9 @@ abstract class DefaultImporter implements ImporterContract
protected BillableRateService $billableRateService;
/**
* Organizations that can not use billable rates import everything without billable flags and rates
*/
protected bool $canUseBillableRates;
public function init(Organization $organization): void
{
$this->organization = $organization;
$this->billableRateService = app(BillableRateService::class);
$this->canUseBillableRates = $this->billableRateService->canUseBillableRates($organization);
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
@@ -106,11 +99,7 @@ abstract class DefaultImporter implements ImporterContract
'string',
'in:placeholder',
],
], beforeSave: function (Member $member): void {
if (! $this->canUseBillableRates) {
$member->billable_rate = null;
}
});
]);
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id);
@@ -137,10 +126,6 @@ abstract class DefaultImporter implements ImporterContract
if ($project->billable_rate === 0) {
$project->billable_rate = null;
}
if (! $this->canUseBillableRates) {
$project->is_billable = false;
$project->billable_rate = null;
}
});
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
/** @var Builder<ProjectMember> $builder */
@@ -155,9 +140,6 @@ abstract class DefaultImporter implements ImporterContract
if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null;
}
if (! $this->canUseBillableRates) {
$projectMember->billable_rate = null;
}
});
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
@@ -199,6 +181,7 @@ abstract class DefaultImporter implements ImporterContract
$this->timeEntriesCreated = 0;
$this->colorService = app(ColorService::class);
$this->timezoneService = app(TimezoneService::class);
$this->billableRateService = app(BillableRateService::class);
}
/**

View File

@@ -134,7 +134,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
if (! in_array($record['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $this->canUseBillableRates && $record['billable'] === 'true';
$timeEntry->billable = $record['billable'] === 'true';
$timeEntry->tags = $this->getTags($record['tags']);
$timeEntry->is_imported = true;
try {
@@ -155,9 +155,6 @@ class GenericTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
if ($end->lt($start)) {
throw new ImportException('Value of end ("'.$record['end'].'") is before start ("'.$record['start'].'")');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,

View File

@@ -115,7 +115,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $this->canUseBillableRates && $record['Billable?'] === 'Yes';
$timeEntry->billable = $record['Billable?'] === 'Yes';
$timeEntry->tags = [];
$timeEntry->is_imported = true;
@@ -136,9 +136,6 @@ class HarvestTimeEntriesImporter extends DefaultImporter
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
}
$hours = (float) $hoursField;
if ($hours < 0) {
throw new ImportException('Hours ("'.$record['Hours'].'") is negative');
}
$timeEntry->start = $date->copy()->startOfDay()->utc();
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(

View File

@@ -211,34 +211,18 @@ class SolidtimeImporter extends DefaultImporter
// Time entries
foreach ($timeEntriesReader as $timeEntryRow) {
// Running time entries are skipped, since the user could already have a running time entry
if ($timeEntryRow['end'] === '') {
continue;
}
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
if ($userId === null) {
throw new ImportException('Time entry references a user that does not exist in the import');
}
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
if ($memberId === null) {
throw new ImportException('Time entry references a member that does not exist in the import');
}
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($timeEntryRow['client_id'] !== '') {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
if ($clientId === null) {
throw new ImportException('Time entry references a client that does not exist in the import');
}
}
$project = null;
$projectId = null;
$projectMember = null;
if ($timeEntryRow['project_id'] !== '') {
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
if ($projectId === null) {
throw new ImportException('Time entry references a project that does not exist in the import');
}
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
@@ -248,9 +232,6 @@ class SolidtimeImporter extends DefaultImporter
$taskId = null;
if ($timeEntryRow['task_id'] !== '') {
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
if ($taskId === null) {
throw new ImportException('Time entry references a task that does not exist in the import');
}
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
@@ -268,7 +249,7 @@ class SolidtimeImporter extends DefaultImporter
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $this->canUseBillableRates && $timeEntryRow['billable'] === 'true';
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
// The type column does not exist in old exports
if (($timeEntryRow['type'] ?? '') !== '') {
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
@@ -290,18 +271,19 @@ class SolidtimeImporter extends DefaultImporter
}
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
if ($timeEntryRow['end'] !== '') {
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
} else {
$timeEntry->end = null;
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end->lt($start)) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is before start date ("'.$timeEntryRow['start'].'")');
}
$timeEntry->end = $end->utc();
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
try {

View File

@@ -118,7 +118,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
try {
@@ -139,9 +139,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($end === null) {
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
}
if ($end->lt($start)) {
throw new ImportException('End ("'.$record['End date'].' '.$record['End time'].'") is before start ("'.$record['Start date'].' '.$record['Start time'].'")');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,

View File

@@ -159,13 +159,6 @@ class MemberService
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
{
// Running time entries are deleted instead of moved, since $toMember could already have a running time entry
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($fromMember, 'member')
->whereNull('end')
->delete();
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')

View File

@@ -133,8 +133,7 @@ class TimeEntryFilter
*/
public function addMemberIdsFilter(?array $memberIds): self
{
// An empty selection is no constraint, the same as null
if ($memberIds === null || count($memberIds) === 0) {
if ($memberIds === null) {
return $this;
}
$this->builder->whereIn('member_id', $memberIds);
@@ -204,10 +203,6 @@ class TimeEntryFilter
}
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no client IDs and not filtering for "none") is no constraint, so apply nothing
if (count($clientIds) === 0 && ! $includeNone) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
if (count($clientIds) > 0) {
@@ -231,10 +226,6 @@ class TimeEntryFilter
}
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no project IDs and not filtering for "none") is no constraint, so apply nothing
if (count($projectIds) === 0 && ! $includeNone) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
if (count($projectIds) > 0) {
@@ -300,10 +291,6 @@ class TimeEntryFilter
}
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no task IDs and not filtering for "none") is no constraint, so apply nothing
if (count($taskIds) === 0 && ! $includeNone) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
if (count($taskIds) > 0) {

737
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@ return [
|
*/
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 10000),
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),

View File

@@ -342,89 +342,6 @@ test('test that a running time entry counts towards the goal progress', async ({
);
});
// Close to midnight UTC an entry that started a few minutes ago could fall on the previous day, so
// the day of the goal is moved to a timezone (without daylight saving time) where it is late morning
function timezoneAwayFromMidnight() {
const utcHour = new Date().getUTCHours();
return utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo';
}
// Navigates via the sidebar, a full page load would start with an empty query cache and hide
// a missing goals invalidation
async function navigateViaSidebar(page: Page, name: 'Time' | 'Goals') {
await page.getByRole('link', { name, exact: true }).first().click();
await expect(page).toHaveURL(new RegExp('/' + name.toLowerCase() + '$'));
}
test('test that goal progress updates after creating a time entry without a page reload', async ({
page,
ctx,
}) => {
const goal = await createGoalViaApi(ctx, {
name: 'Cache goal',
target_seconds: 3600,
period: 'day',
timezone: timezoneAwayFromMidnight(),
});
await goToGoalsOverview(page);
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
await navigateViaSidebar(page, 'Time');
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
await durationInput.fill('15min');
await durationInput.press('Tab');
await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.getByRole('button', { name: 'Create Time Entry' }).click(),
]);
await navigateViaSidebar(page, 'Goals');
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 15min');
});
test('test that goal progress updates after changing the project of the running time entry without a page reload', async ({
page,
ctx,
}) => {
const project = await createProjectViaApi(ctx, { name: 'Running Project', is_public: true });
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
const goal = await createGoalViaApi(ctx, {
name: 'Running project goal',
target_seconds: 3600,
period: 'day',
timezone: timezoneAwayFromMidnight(),
filters: { project_ids: [project.id], time_entry_type: 'work' },
});
await goToGoalsOverview(page);
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
await navigateViaSidebar(page, 'Time');
await page.getByRole('button', { name: 'No Project' }).click();
await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.project_id === project.id
),
page.getByRole('option').filter({ hasText: project.name }).click(),
]);
await navigateViaSidebar(page, 'Goals');
// The entry keeps running while the test runs
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
/^0h 3[0-2]min$/
);
});
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
const goal = await createGoalViaApi(ctx, {
name: 'Old goal name',

View File

@@ -884,7 +884,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await expect(table).toBeVisible();
// First click = desc (highest first), null rates last
const billableHeader = table.getByRole('button', { name: /Billable Rate/ });
const billableHeader = table.getByText('Billable Rate').first();
await billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table);

View File

@@ -1,14 +0,0 @@
import { expect } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
import type { TestContext } from './api';
/**
* End the trial of the test organization, only works with the Billing extension. Reload the page
* afterwards, the page props still contain the trial otherwise.
*/
export async function cancelTrialViaApi(ctx: TestContext): Promise<void> {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/trial/cancel`
);
expect(response.status()).toBe(204);
}

View File

@@ -1,7 +1,7 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.11"
"ref": "v0.0.9"
},
"Services": {
"repository": "solidtime-io/extension-services",
@@ -9,7 +9,7 @@
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.12"
"ref": "v0.0.10"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",

View File

@@ -19,7 +19,6 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
@@ -32,7 +31,6 @@ return [
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
TimeEntryMemberCanNotBeChangedWhileRunningApiException::KEY => 'The member of a running time entry can not be changed',
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',

View File

@@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { enabledExtensions } from './playwright/config';
import { existsSync, readFileSync } from 'node:fs';
/**
* Read environment variables from file.
@@ -7,11 +7,23 @@ import { enabledExtensions } from './playwright/config';
*/
// require('dotenv').config();
/*
/**
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
* a checked out but disabled extension would fail its specs otherwise.
*
*/
function enabledExtensions(): string[] {
const statusesFile = './modules_statuses.json';
if (!existsSync(statusesFile)) {
return [];
}
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
return Object.entries(statuses)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
}
/*
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
*/

View File

@@ -1,23 +1,3 @@
import { existsSync, readFileSync } from 'node:fs';
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
export const TEST_USER_PASSWORD = 'amazingpassword123';
/**
* Extensions that are enabled in modules_statuses.json (php artisan module:enable <Name>)
*/
export function enabledExtensions(): string[] {
const statusesFile = './modules_statuses.json';
if (!existsSync(statusesFile)) {
return [];
}
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
return Object.entries(statuses)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
}
export function isExtensionEnabled(name: string): boolean {
return enabledExtensions().includes(name);
}

View File

@@ -1,21 +1,137 @@
<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { isBillingActivated, isBlocked } from '@/utils/billing';
import {
daysLeftInTrial,
isBillingActivated,
isBlocked,
isFreePlan,
isInTrial,
} from '@/utils/billing';
import { useSessionStorage } from '@vueuse/core';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { canManageBilling } from '@/utils/permissions';
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
const hideBlockedBanner = useSessionStorage(
'showBlockedBanner-' + getCurrentOrganizationId(),
false
);
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
const hideFreeUpgradeBanner = useSessionStorage(
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
false
);
const showFreeUpgradeBanner = computed(
() =>
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
);
const hideBlackFridayBanner = useSessionStorage(
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
false
);
const showBlackFridayBanner = computed(() => {
if (hideBlackFridayBanner.value) {
return false;
}
const today = new Date();
const blackFriday = new Date(2024, 10, 30);
return today < blackFriday;
});
</script>
<template>
<div
v-if="showBlackFridayBanner"
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path
fill="#FF37AD"
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
<path
fill="#FCC954"
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
<path
fill="#2890E9"
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
<path
fill="#F0A420"
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
<path
fill="#FCC954"
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
<path
fill="#F0A420"
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
<path
fill="#2E6AC9"
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
<path
fill="#2890E9"
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
<path
fill="#FF37AD"
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
<path
fill="#2890E9"
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
</svg>
<div class="flex-1 space-x-1">
<span class="font-medium">
<strong>BLACK FRIDAY SALE!</strong> Use the code
<strong>BLACKFRIDAY</strong> at checkout and get
<strong>30% off</strong> the solidtime yearly plan.
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideBlackFridayBanner = true">
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
<div
v-if="showTrialBanner"
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
Your trial expires in {{ daysLeftInTrial() }} days.
</span>
<span class="hidden md:inline">
To continue using all features & support the development of solidtime,
please upgrade your plan.
</span>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideTrialBanner = true">
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
<div
v-if="showBlockedBanner"
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
@@ -43,6 +159,33 @@ const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value
</div>
</MainContainer>
</div>
<div
v-if="showFreeUpgradeBanner"
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1">
<span class="font-medium"> You are currently using the Free Plan. </span>
<span class="hidden md:inline">
To unlock all premium features & support the development of solidtime,
please upgrade your plan.</span
>
</div>
</div>
<div class="flex items-center space-x-2">
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<div
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
<button class="p-1" @click="hideFreeUpgradeBanner = true">
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
</button>
</div>
</MainContainer>
</div>
</template>
<style scoped></style>

View File

@@ -1,86 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { router } from '@inertiajs/vue3';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { useNotificationsStore } from '@/utils/notification';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
const show = defineModel<boolean>('show', { default: false });
const processing = ref(false);
const { handleApiRequestNotifications } = useNotificationsStore();
const lostFeatures = [
'Billable hours & rates',
'Invoicing',
'Project & task estimates',
'Rounding of time entries',
'PDF reports & shareable reports',
'Team support & access management',
];
async function cancelTrial() {
// Note: The organization of this page, the current organization on the server can differ if the user switched organizations in another tab
const organizationId = getCurrentOrganizationId();
if (processing.value || organizationId === null) {
return;
}
processing.value = true;
try {
await handleApiRequestNotifications(
() => api.cancelTrial(undefined, { params: { organization: organizationId } }),
'Your trial has been canceled',
'Your trial could not be canceled, please try again',
() => {
show.value = false;
// Reloads the shared billing props of the page, they still contain the trial otherwise
router.reload();
}
);
} finally {
processing.value = false;
}
}
</script>
<template>
<DialogModal :show="show" closeable @close="show = false">
<template #title>Cancel trial</template>
<template #content>
<p class="text-sm text-text-secondary">
Your organization switches to the Free plan right away. You will lose access to
these Professional features:
</p>
<ul class="mt-4 grid gap-2 sm:grid-cols-2">
<li
v-for="feature in lostFeatures"
:key="feature"
class="flex items-center gap-2 text-sm text-text-primary">
<XMarkIcon class="h-4 w-4 shrink-0 text-red-500/80" />
{{ feature }}
</li>
</ul>
<p class="mt-5 text-sm text-text-secondary">
Organizations with more than one member are blocked until you upgrade or remove
everyone except the owner.
</p>
</template>
<template #footer>
<SecondaryButton @click="show = false">Keep trial</SecondaryButton>
<DangerButton
class="ms-3"
data-testid="confirm_cancel_trial"
:class="{ 'opacity-25': processing }"
:disabled="processing"
@click="cancelTrial">
Cancel trial
</DangerButton>
</template>
</DialogModal>
</template>

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router } from '@inertiajs/vue3';
import { SparklesIcon } from '@heroicons/vue/16/solid';
import { Button } from '@/packages/ui/src/Buttons';
import TrialUpgradeModal from '@/Components/Billing/TrialUpgradeModal.vue';
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
import { daysLeftInTrial, isBlocked, isFreePlan, isInTrial } from '@/utils/billing';
const showTrialUpgradeModal = ref(false);
const showCancelTrialModal = ref(false);
const daysLeftLabel = computed(() => {
const days = daysLeftInTrial();
return days === 1 ? '1 day left' : `${days} days left`;
});
function openBilling() {
router.visit('/billing');
}
</script>
<template>
<div v-if="isInTrial() || (isFreePlan() && !isBlocked())" class="pb-3">
<Button
v-if="isInTrial()"
variant="outline"
size="sm"
data-testid="sidebar_trial_days_left"
class="w-full justify-between rounded-lg px-2.5 text-xs font-normal [&_svg]:size-3.5"
@click="showTrialUpgradeModal = true">
<span class="flex items-center gap-1.5 font-medium text-text-primary">
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
Professional trial
</span>
<span class="text-text-secondary">{{ daysLeftLabel }}</span>
</Button>
<div
v-else-if="isFreePlan() && !isBlocked()"
class="flex items-center justify-between gap-2 rounded-lg border border-border-secondary bg-card-background py-1.5 pl-2.5 pr-1.5 text-xs">
<span class="font-medium text-text-primary">Free plan</span>
<Button
size="xs"
data-testid="sidebar_upgrade_button"
class="h-6 bg-accent-500 px-2.5 font-semibold text-white hover:bg-accent-400"
@click="openBilling">
Upgrade
</Button>
</div>
<TrialUpgradeModal
v-model:show="showTrialUpgradeModal"
@cancel-trial="showCancelTrialModal = true" />
<CancelTrialModal v-model:show="showCancelTrialModal" />
</div>
</template>

View File

@@ -1,79 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/packages/ui/src/dialog';
import { Button } from '@/packages/ui/src/Buttons';
import { SparklesIcon } from '@heroicons/vue/16/solid';
import { featureIcons } from '@/packages/ui/src/UpgradeModal/featureIcons';
import { professionalFeatures, useUpgradeCta } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
import { daysLeftInTrial } from '@/utils/billing';
const show = defineModel<boolean>('show', { default: false });
const emit = defineEmits<{ cancelTrial: [] }>();
const { goToBilling } = useUpgradeCta();
const daysLeftLabel = computed(() => {
const days = daysLeftInTrial();
return days === 1 ? '1 day' : `${days} days`;
});
function cancelTrial() {
show.value = false;
emit('cancelTrial');
}
</script>
<template>
<Dialog v-model:open="show">
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
<div class="px-8 pt-8 text-center">
<div
class="mx-auto inline-flex items-center gap-1.5 rounded-full border border-border-secondary bg-card-background px-3 py-1 text-xs font-medium text-text-primary">
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
{{ daysLeftLabel }} left in your trial
</div>
<DialogTitle class="mt-4 text-xl font-semibold tracking-tight text-text-primary">
Keep solidtime Professional
</DialogTitle>
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
Upgrade now to keep these features after your trial ends.
</DialogDescription>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-8">
<div v-for="item in professionalFeatures" :key="item.key" class="flex gap-3">
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
<component
:is="featureIcons[item.key]"
class="h-3.5 w-3.5 text-text-secondary" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
{{ item.description }}
</p>
</div>
</div>
</div>
<div class="mx-auto flex max-w-xs flex-col items-center gap-3 px-8 pt-8 pb-6">
<Button
data-testid="trial_upgrade_modal_cta"
class="w-full bg-accent-500 px-4 font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 hover:bg-accent-400"
@click="goToBilling">
Upgrade to Professional
</Button>
<Button
variant="ghost"
size="xs"
data-testid="trial_upgrade_modal_cancel_trial"
class="font-normal text-text-tertiary hover:text-text-primary"
@click="cancelTrial">
Cancel trial
</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -29,7 +29,6 @@ import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
@@ -54,7 +53,6 @@ const {
showCreateTaskModal,
showCreateTagModal,
showCreateGoalModal,
showGoalUpgradeModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,
@@ -187,7 +185,6 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
<!-- Goal Create Modal -->
<GoalCreateModal v-model:show="showCreateGoalModal" />
<UpgradeModal v-model:show="showGoalUpgradeModal" feature="goals" />
<!-- Member Invite Modal -->
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />

View File

@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import GoalCreateButton from './GoalCreateButton.vue';
function mountGoalCreateButton(canCreateMoreGoals: boolean) {
return mount(GoalCreateButton, {
props: { canCreateMoreGoals },
slots: { default: 'Create your first goal' },
global: {
stubs: {
GoalCreateModal: { name: 'GoalCreateModal', props: ['show'], template: '<div />' },
UpgradeModal: {
name: 'UpgradeModal',
props: ['show', 'feature'],
template: '<div />',
},
},
},
});
}
describe('GoalCreateButton', () => {
it('opens the create modal while more goals can be created', async () => {
const wrapper = mountGoalCreateButton(true);
await wrapper.get('button').trigger('click');
expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(true);
expect(wrapper.getComponent({ name: 'UpgradeModal' }).props('show')).toBe(false);
});
it('opens the upgrade modal once the goal limit is reached', async () => {
const wrapper = mountGoalCreateButton(false);
expect(wrapper.get('button').text()).toBe('Create your first goal');
await wrapper.get('button').trigger('click');
const upgradeModal = wrapper.getComponent({ name: 'UpgradeModal' });
expect(upgradeModal.props('show')).toBe(true);
expect(upgradeModal.props('feature')).toBe('goals');
expect(wrapper.getComponent({ name: 'GoalCreateModal' }).props('show')).toBe(false);
});
});

View File

@@ -1,31 +0,0 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
const props = defineProps<{
canCreateMoreGoals: boolean;
}>();
const showCreateGoalModal = ref(false);
const showUpgradeModal = ref(false);
// The button stays visible when the goal limit is reached and explains the limit instead
function onClick() {
if (props.canCreateMoreGoals) {
showCreateGoalModal.value = true;
} else {
showUpgradeModal.value = true;
}
}
</script>
<template>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
<UpgradeModal v-model:show="showUpgradeModal" feature="goals" />
<SecondaryButton :icon="PlusIcon" @click="onClick">
<slot>Create Goal</slot>
</SecondaryButton>
</template>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { Target } from '@lucide/vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import type { Goal } from '@/packages/api/src';
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import { canCreateGoals } from '@/utils/permissions';
import { LoadingSpinner } from '@/packages/ui/src';
import { isGoalsExtensionActivated } from '@/utils/billing';
@@ -15,6 +18,7 @@ const props = defineProps<{
showingArchived: boolean;
}>();
const showCreateGoalModal = ref(false);
const showTargetColumn = isGoalsExtensionActivated();
// Every column gets a share of the free space so the columns are spread evenly over the table
const gridTemplateColumns = [
@@ -29,6 +33,7 @@ const gridTemplateColumns = [
</script>
<template>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
@@ -46,11 +51,14 @@ const gridTemplateColumns = [
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
Set yourself a target and track your progress every day, week or month.
</p>
<GoalCreateButton
v-if="canCreateGoals() && !props.showingArchived"
:can-create-more-goals="props.canCreateMoreGoals"
<SecondaryButton
v-if="
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create your first goal
</GoalCreateButton>
</SecondaryButton>
</div>
<template v-for="goal in props.goals" :key="goal.id">
<GoalTableRow

View File

@@ -43,7 +43,6 @@ const deleteMutation = useMutation({
onSuccess: () => {
close();
queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
},
});

View File

@@ -21,7 +21,6 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const { updateMember } = useMembersStore();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
@@ -72,8 +71,6 @@ function saveWithChecks() {
}
}
const { locked: billableRatesLocked } = useBillableRatesLock();
const billableRateSelect = ref<MemberBillableKey>('default-rate');
onMounted(() => {
@@ -162,12 +159,7 @@ const roleDescription = computed(() => {
<FieldLabel :icon="BillableIcon" for="billableRateType"
>Billable Rate</FieldLabel
>
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
name="memberBillableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="billableRateSelect">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -52,7 +52,6 @@ async function submit() {
'There was an error merging the members.',
() => {
queryClient.invalidateQueries({ queryKey: ['members'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
show.value = false;
}
);

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
@@ -26,7 +25,6 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money';
import {
@@ -73,8 +71,6 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -93,9 +89,8 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="member.billable_rate">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="member.billable_rate">
{{
formatCents(
member.billable_rate,

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -16,11 +15,10 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
@@ -80,8 +78,6 @@ const billableRateInfo = computed(() => {
});
const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -104,15 +100,13 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div
class="whitespace-nowrap min-w-0 px-3 py-4 flex items-center text-sm text-text-primary">
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else class="text-text-tertiary">No client</div>
</div>
<div
class="whitespace-nowrap px-3 py-4 flex items-center text-sm text-text-primary">
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
@@ -125,9 +119,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div v-else class="text-text-tertiary">--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"
feature="estimates"></UpgradeBadge>
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
@@ -136,9 +128,8 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div>
<div

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { ProjectMember } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import TableRow from '@/Components/TableRow.vue';
@@ -11,7 +10,6 @@ import { capitalizeFirstLetter } from '@/utils/format';
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import type { Organization } from '@/packages/api/src';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
const props = defineProps<{
projectMember: ProjectMember;
@@ -35,8 +33,6 @@ const member = computed(() => {
return members.value.find((member) => member.id === props.projectMember.member_id);
});
const showEditModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -51,21 +47,18 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
{{ member?.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else>
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</span>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
projectMember.billable_rate
? formatCents(
projectMember.billable_rate,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member?.role ?? '') }}

View File

@@ -4,7 +4,7 @@ import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'
import { h, ref } from 'vue';
import type { CreateReportBodyProperties } from '@/packages/api/src';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
defineProps<{
reportProperties: CreateReportBodyProperties;
@@ -30,7 +30,9 @@ function onSaveReportClick() {
<ReportCreateModal
v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal" feature="shareable-reports" />
<UpgradeModal v-model:show="showPremiumModal">
<strong>Sharable Reports</strong> is only available in solidtime Professional.
</UpgradeModal>
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
>Save Report</SecondaryButton
>

View File

@@ -10,7 +10,7 @@ import {
import type { ExportFormat } from '@/types/reporting';
import { ref } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
const props = defineProps<{
download: (format: ExportFormat) => Promise<void>;
@@ -48,7 +48,9 @@ function triggerDownload(format: ExportFormat) {
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
<UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
</template>
<style scoped></style>

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { CheckCircleIcon, LockClosedIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check, Coffee } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
@@ -13,7 +12,6 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Button } from '@/packages/ui/src/Buttons';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -37,8 +35,6 @@ const roundingMinutes = defineModel<number>('roundingMinutes', { required: true
const startDate = defineModel<string>('startDate', { required: true });
const endDate = defineModel<string>('endDate', { required: true });
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
const emit = defineEmits<{
submit: [];
}>();
@@ -145,17 +141,7 @@ async function createTag(name: string) {
</template>
</TagDropdown>
<Button
v-if="billableRatesLocked"
variant="outline"
size="sm"
data-testid="billable_filter_locked"
@click="requestUpgrade">
<BillableIcon class="h-4 text-text-quaternary" />
<span class="text-text-secondary">Billable</span>
<LockClosedIcon class="!w-3 text-text-tertiary" />
</Button>
<Select v-else v-model="billable" @update:model-value="emit('submit')">
<Select v-model="billable" @update:model-value="emit('submit')">
<SelectTrigger
size="sm"
variant="outline"

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
ChartBarIcon,
ArrowDownTrayIcon,
@@ -32,8 +30,7 @@ import {
DropdownMenuTrigger,
} from '@/packages/ui/src';
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import type { UpgradeFeature } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { canCreateReports } from '@/utils/permissions';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
@@ -217,13 +214,10 @@ const showExportModal = ref(false);
const exportUrl = ref<string | null>(null);
const showCreateReportModal = ref(false);
const showPremiumModal = ref(false);
// Note: The premium modal is shared by the PDF export and saving a report
const premiumFeature = ref<UpgradeFeature>('pdf-reports');
const exportLoading = ref(false);
function triggerExport(format: ExportFormat) {
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
premiumFeature.value = 'pdf-reports';
showPremiumModal.value = true;
return;
}
@@ -237,7 +231,6 @@ function onSaveReportClick() {
if (isAllowedToPerformPremiumAction()) {
showCreateReportModal.value = true;
} else {
premiumFeature.value = 'shareable-reports';
showPremiumModal.value = true;
}
}
@@ -297,8 +290,6 @@ const tableData = computed(() => {
};
});
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -308,7 +299,9 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<ReportCreateModal
v-model:show="showCreateReportModal"
:properties="reportProperties"></ReportCreateModal>
<UpgradeModal v-model:show="showPremiumModal" :feature="premiumFeature" />
<UpgradeModal v-model:show="showPremiumModal">
This feature is only available in solidtime Professional.
</UpgradeModal>
<MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
@@ -454,20 +447,17 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
</div>
</template>

View File

@@ -2,8 +2,6 @@
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { ref, inject, type ComputedRef } from 'vue';
import { twMerge } from 'tailwind-merge';
import type { Organization } from '@/packages/api/src';
@@ -29,8 +27,6 @@ const props = defineProps<{
const expanded = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -57,20 +53,17 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
}}
</div>
<div v-if="showCost" class="justify-end pr-6 flex items-center">
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
entry.cost
? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
{{
entry.cost
? formatCents(
entry.cost,
props.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
</div>
<div

View File

@@ -9,7 +9,7 @@ defineProps<{
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-medium">
<slot>{{ value ?? '--' }}</slot>
{{ value ?? '--' }}
</dd>
</div>
</template>

View File

@@ -9,7 +9,7 @@ import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
import { ref, inject, type ComputedRef } from 'vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
import type { Organization } from '@/packages/api/src';
@@ -55,9 +55,7 @@ const showTaskEditModal = ref(false);
<span v-else> -- </span>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge
v-if="!isAllowedToPerformPremiumAction()"
feature="estimates"></UpgradeBadge>
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="task.estimated_time"
:estimated="task.estimated_time"

View File

@@ -1,21 +1,17 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import UpgradeModal from './UpgradeModal.vue';
import type { UpgradeFeature } from './UpgradeModal/useUpgradeCta';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { ref } from 'vue';
defineProps<{
// The Professional feature behind the badge, picks the variant of the upgrade modal
feature: UpgradeFeature;
}>();
const showUpgradeModal = ref(false);
</script>
<template>
<UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
<UpgradeModal v-model:show="showUpgradeModal">
<slot>
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
</slot>
</UpgradeModal>
<button
data-testid="upgrade_badge"
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
@click.prevent="showUpgradeModal = true">
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Link } from '@inertiajs/vue3';
import { isBillingActivated } from '@/utils/billing';
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
import { SecondaryButton } from '@/packages/ui/src';
const show = defineModel('show', { default: false });
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Upgrade Plan </span>
</div>
</template>
<template #content>
<div>
<div
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
<UserGroupIcon class="w-12"></UserGroupIcon>
</div>
<div class="max-w-sm text-center mx-auto py-4 text-base">
<p class="py-1">
<slot></slot>
</p>
<p class="py-1 text-sm">
If you want to use this feature,
<strong class="font-semibold text-text-primary"
>please upgrade to a paid plan</strong
>
or
<strong class="font-semibold text-text-primary"
>request a free trial</strong
>
via
<a
class="text-accent-200/80 transition text-accent-300"
href="mailto:hello@solidtime.io"
>hello@solidtime.io</a
>
to try out this feature.
</p>
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
<PrimaryButton
v-if="isBillingActivated() && canUpdateOrganization()"
type="button"
class="mt-6"
:icon="CreditCardIcon">
Go to Billing
</PrimaryButton>
</Link>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false">Close</SecondaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
@@ -103,8 +101,6 @@ const { data: totalWeeklyBillableTime } = useQuery({
staleTime: 1000 * 30, // 30 seconds
});
const { locked: billableRatesLocked } = useBillableRatesLock();
const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => {
@@ -114,7 +110,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
},
});
},
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
enabled: computed(() => !!organizationId.value),
staleTime: 1000 * 30, // 30 seconds
});
@@ -274,11 +270,7 @@ const option = computed(() => {
)
: '--'
" />
<StatCard v-if="billableRatesLocked" title="Billable Amount">
<UpgradeLockedValue />
</StatCard>
<StatCard
v-else
title="Billable Amount"
:value="
totalWeeklyBillableAmount

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import {
@@ -128,8 +126,6 @@ const showBillableRate = computed(() => {
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
);
});
const { locked: billableRatesLocked } = useBillableRatesLock();
</script>
<template>
@@ -194,20 +190,17 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div
v-if="showBillableRate"
class="justify-end pr-6 flex items-center font-medium">
<UpgradeLockedValue v-if="billableRatesLocked" />
<template v-else>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</template>
{{
aggregatedTableTimeEntries.cost
? formatCents(
aggregatedTableTimeEntries.cost,
getOrganizationCurrencyString(),
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head, router, usePage } from '@inertiajs/vue3';
import { Head, usePage } from '@inertiajs/vue3';
import Banner from '@/Components/Banner.vue';
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
@@ -23,7 +23,7 @@ import { PanelLeft, Target } from '@lucide/vue';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { computed, nextTick, onMounted, provide, ref } from 'vue';
import { nextTick, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -38,16 +38,12 @@ import {
canViewReport,
canViewTags,
} from '@/utils/permissions';
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
import { upgradeCtaKey } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
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 SidebarPlanStatus from '@/Components/Billing/SidebarPlanStatus.vue';
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
import { useTheme } from '@/utils/theme';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -93,19 +89,6 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
provide('organization', organization);
const showBillableRatesUpgradeModal = ref(false);
provide(billableRatesLockKey, {
locked: computed(() => !canUseBillableRates()),
requestUpgrade: () => {
showBillableRatesUpgradeModal.value = true;
},
});
provide(upgradeCtaKey, {
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
goToBilling: () => router.visit('/billing'),
});
onMounted(async () => {
useTheme();
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
@@ -323,7 +306,6 @@ const page = usePage<{
</div>
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<SidebarPlanStatus v-if="isBillingActivated() && canManageBilling()" />
<ul
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
@@ -402,5 +384,4 @@ const page = usePage<{
<NotificationContainer></NotificationContainer>
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
<CommandPaletteProvider></CommandPaletteProvider>
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
</template>

View File

@@ -2,15 +2,19 @@
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { Target } from '@lucide/vue';
import { PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { computed, ref } from 'vue';
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
import { canCreateGoals } from '@/utils/permissions';
import { canCreateMoreGoals } from '@/utils/billing';
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
import { useGoalsQuery } from '@/utils/useGoalsQuery';
import { TabBar, TabBarItem } from '@/packages/ui/src';
const showCreateGoalModal = ref(false);
const activeTab = ref('active');
const showArchived = computed(() => activeTab.value === 'archived');
@@ -21,6 +25,7 @@ const visibleGoals = computed(() =>
);
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
const limit = goalLimit();
</script>
<template>
@@ -37,9 +42,19 @@ const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
</TabBarItem>
</TabBar>
</div>
<GoalCreateButton
v-if="canCreateGoals()"
:can-create-more-goals="canCreateMore"></GoalCreateButton>
<div class="flex items-center space-x-3">
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
available in solidtime Professional.
</UpgradeBadge>
<SecondaryButton
v-if="canCreateGoals() && canCreateMore"
:icon="PlusIcon"
@click="showCreateGoalModal = true"
>Create Goal
</SecondaryButton>
</div>
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
</MainContainer>
<GoalTable
:goals="visibleGoals"

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { canUseBillableRates } from '@/utils/billing';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
@@ -82,9 +80,6 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done;
});
});
// Note: The page sits above the app layout that provides the lock, so it can not inject it
const billableRatesLocked = computed(() => !canUseBillableRates());
</script>
<template>
@@ -122,17 +117,11 @@ const billableRatesLocked = computed(() => !canUseBillableRates());
</li>
</ol>
<div class="px-4 space-x-1">
<UpgradeLockedBadge v-if="billableRatesLocked"
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
<Badge v-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
</Badge>
<Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
<Badge v-if="project?.is_billable && !project?.billable_rate">
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

@@ -43,7 +43,7 @@ import { useMembersQuery } from '@/utils/useMembersQuery';
import { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -253,7 +253,9 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportModal
v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal>
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
<UpgradeModal v-model:show="showPremiumModal">
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
<MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">

View File

@@ -5,12 +5,7 @@ import { useNotificationsStore } from '@/utils/notification';
import { api } from '@/packages/api/src';
import { Field, FieldLabel } from '@/packages/ui/src/field';
import { DocumentIcon } from '@heroicons/vue/24/solid';
import {
ArrowDownOnSquareIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from '@heroicons/vue/24/outline';
import { canUseBillableRates } from '@/utils/billing';
import { ArrowDownOnSquareIcon, InformationCircleIcon } from '@heroicons/vue/24/outline';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { ImportReport, ImportType } from '@/packages/api/src';
@@ -26,8 +21,6 @@ const { addNotification } = useNotificationsStore();
const loading = ref(false);
const billableRatesLocked = computed(() => !canUseBillableRates());
onMounted(async () => {
const organizationId = getCurrentOrganizationId();
if (organizationId) {
@@ -162,19 +155,8 @@ const showResultModal = ref(false);
<div class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3">
<InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
<p class="flex-1">
Import existing data from Toggl, Clockify, Harvest, generic CSV files or a
different solidtime instance. Please select the type of data you want to import
and follow the instructions.
</p>
</div>
<div
v-if="billableRatesLocked"
class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3 border-t border-card-background-separator">
<ExclamationTriangleIcon class="h-5 min-w-0 w-5 text-yellow-500" />
<p class="flex-1">
Billable rates and billable status will not be imported, since they are not
available in the free plan. All projects and time entries will be imported as
non-billable.
Import existing data from Toggl, Clockify or a different solidtime instance.
Please select the type of data you want to import and follow the instructions.
</p>
</div>
</Card>

View File

@@ -10,12 +10,10 @@ import { storeToRefs } from 'pinia';
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { Checkbox } from '@/packages/ui/src';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { organization } = storeToRefs(store);
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
const saving = ref(false);
const organizationBody = ref<UpdateOrganizationBody>({
name: '',
@@ -77,8 +75,9 @@ function checkForConfirmationModal() {
<Checkbox
v-if="organization"
id="organizationShowBillableRatesToEmployees"
v-model:checked="organizationBody.employees_can_see_billable_rates"
:disabled="billableRatesLocked"></Checkbox>
v-model:checked="
organizationBody.employees_can_see_billable_rates
"></Checkbox>
<FieldLabel for="organizationShowBillableRatesToEmployees"
>Show Billable Rates to Employees</FieldLabel
>
@@ -86,10 +85,7 @@ function checkForConfirmationModal() {
</div>
</template>
<template #actions>
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
>Upgrade</PrimaryButton
>
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import { computed, inject, ref, type ComputedRef } from 'vue';
import { usePage } from '@inertiajs/vue3';
import ActionSection from '@/Components/ActionSection.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
import { daysLeftInTrial } from '@/utils/billing';
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
import type { Organization } from '@/packages/api/src';
const showCancelTrialModal = ref(false);
const organization = inject<ComputedRef<Organization | undefined>>('organization');
const page = usePage<{ billing: { trial_until: string | null } | null }>();
const trialUntil = computed(() =>
page.props.billing?.trial_until
? formatDateLocalized(page.props.billing.trial_until, organization?.value?.date_format)
: null
);
const daysLeftLabel = computed(() => {
const days = daysLeftInTrial();
return days === 1 ? '1 day' : `${days} days`;
});
</script>
<template>
<ActionSection>
<template #title>Trial</template>
<template #description>Your organization is trying out solidtime Professional.</template>
<template #content>
<div class="max-w-xl text-sm text-text-secondary">
Your trial ends in
<span class="font-medium text-text-primary">{{ daysLeftLabel }}</span>
<template v-if="trialUntil"> ({{ trialUntil }})</template>. If you don't need the
Professional features, you can switch to the Free plan right away.
</div>
<div class="mt-5">
<SecondaryButton
data-testid="cancel_trial_button"
@click="showCancelTrialModal = true">
Cancel trial
</SecondaryButton>
</div>
<CancelTrialModal v-model:show="showCancelTrialModal" />
</template>
</ActionSection>
</template>

View File

@@ -8,9 +8,6 @@ import type { Permissions } from '@/types/jetstream';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
import OrganizationTrial from '@/Pages/Teams/Partials/OrganizationTrial.vue';
import { isBillingActivated, isInTrial } from '@/utils/billing';
import { canManageBilling } from '@/utils/permissions';
import { onMounted, ref } from 'vue';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
@@ -55,11 +52,6 @@ onMounted(async () => {
<SectionBorder v-if="permissions.canUpdateTeam" />
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
<template v-if="isBillingActivated() && isInTrial() && canManageBilling()">
<SectionBorder />
<OrganizationTrial />
</template>
<template v-if="permissions.canDeleteTeam">
<SectionBorder />
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />

View File

@@ -326,7 +326,6 @@ const IntervalFormat = z.enum([
const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z
.object({
can_use_billable_rates: z.boolean().optional(),
id: z.string(),
name: z.string(),
is_personal: z.boolean(),
@@ -5070,45 +5069,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/trial/cancel',
alias: 'cancelTrial',
description: `The organization switches to the free plan right away.`,
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({ error: z.boolean(), key: z.string(), message: z.string() })
.passthrough(),
},
{
status: 401,
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`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'get',
path: '/v1/public/reports',

View File

@@ -8,8 +8,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '..';
import UpgradeLockedField from './UpgradeLockedField.vue';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const props = defineProps<{
name: string;
@@ -22,8 +20,6 @@ const model = defineModel<number | null>({
default: null,
});
const { locked } = useBillableRatesLock();
const billableRateInput = ref<HTMLInputElement | null>(null);
useFocus(billableRateInput, { initialValue: props.focus });
@@ -34,9 +30,7 @@ function formatValue(modelValue: number | null) {
<template>
<div class="relative">
<UpgradeLockedField v-if="locked" />
<NumberField
v-else
:id="name"
ref="billableRateInput"
:model-value="formatValue(model)"

View File

@@ -2,7 +2,6 @@
import { computed } from 'vue';
import { twMerge } from 'tailwind-merge';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import {
Tooltip,
TooltipContent,
@@ -11,13 +10,7 @@ import {
} from '@/packages/ui/src/tooltip';
const active = defineModel<boolean>({ default: false });
const emit = defineEmits(['changed']);
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
function toggleBillable() {
// Organizations with locked billable rates can remove the billable flag, but not set it
if (billableRatesLocked.value && !active.value) {
requestUpgrade();
return;
}
active.value = !active.value;
emit('changed', active.value);
}
@@ -33,12 +26,7 @@ const props = withDefaults(
}
);
const tooltipLabel = computed(() => {
if (billableRatesLocked.value && !active.value) {
return 'Billable rates require an upgrade';
}
return active.value ? 'Billable' : 'Non Billable';
});
const tooltipLabel = computed(() => (active.value ? 'Billable' : 'Non Billable'));
const iconColorClasses = computed(() => {
if (active.value) {

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const { requestUpgrade } = useBillableRatesLock();
</script>
<template>
<button
type="button"
data-testid="billable_rates_locked"
class="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input-border bg-input-background px-3 text-left text-sm text-text-tertiary shadow-sm transition hover:border-border-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click.prevent.stop="requestUpgrade">
<span class="flex min-w-0 items-center gap-2">
<LockClosedIcon class="w-3.5 shrink-0 text-icon-default" />
<span class="truncate"><slot>Billable rates</slot></span>
</span>
<span
class="shrink-0 rounded border border-border-secondary bg-secondary px-2 py-0.5 text-xs font-semibold text-text-secondary">
Upgrade
</span>
</button>
</template>

View File

@@ -10,27 +10,21 @@ import {
} from '@/packages/ui/src/tooltip';
import { computed, onMounted, ref, watch } from 'vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const props = defineProps<{
currency: string;
organizationBillableRate: number | null;
}>();
const { locked: billableRatesLocked } = useBillableRatesLock();
type RateType = 'default-rate' | 'custom-rate';
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
const rateType = ref<RateType>('default-rate');
const originallyBillable = ref(false);
const billableFlagLocked = computed(() => billableRatesLocked.value && !originallyBillable.value);
const billableRate = defineModel<number | null>('billableRate');
const isBillable = defineModel<boolean>('isBillable');
onMounted(() => {
originallyBillable.value = isBillable.value === true;
if (isBillable.value === true) {
billableDefault.value = 'billable';
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
@@ -72,9 +66,6 @@ const displayedRate = computed({
});
const billableDescription = computed(() => {
if (billableFlagLocked.value) {
return 'Your organization’s plan does not allow enabling billable projects.';
}
if (billableDefault.value === 'non-billable') {
return 'New time entries for this project will not be marked billable by default.';
}
@@ -93,19 +84,14 @@ const emit = defineEmits(['submit']);
</SelectTrigger>
<SelectContent>
<SelectItem value="non-billable">Non-billable</SelectItem>
<SelectItem value="billable" :disabled="billableFlagLocked">Billable</SelectItem>
<SelectItem value="billable">Billable</SelectItem>
</SelectContent>
</Select>
<FieldDescription>{{ billableDescription }}</FieldDescription>
</Field>
<Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="currency"
name="billableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType">
<SelectTrigger id="billableRateType">
<SelectValue />

View File

@@ -16,14 +16,12 @@ import type {
} from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import type { Tag, Task } from '@/packages/api/src';
const show = defineModel('show', { default: false });
const { locked: billableRatesLocked } = useBillableRatesLock();
const saving = ref(false);
const props = defineProps<{
@@ -191,9 +189,7 @@ const billableProxy = computed({
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true" :disabled="billableRatesLocked"
>Billable</SelectItem
>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>

View File

@@ -17,7 +17,6 @@ import type {
} from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
@@ -65,7 +64,6 @@ watch(show, (value) => {
});
const editableTimeEntry = ref<TimeEntry | null>(null);
const { locked: billableRatesLocked } = useBillableRatesLock();
watch(
() => props.timeEntry,
@@ -136,11 +134,6 @@ async function deleteEntry() {
}
}
// Organizations with locked billable rates can not make a non-billable time entry billable
const billableOptionLocked = computed(
() => billableRatesLocked.value && !props.timeEntry?.billable
);
const billableProxy = computed({
get: () =>
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
@@ -252,9 +245,7 @@ const typeProxy = computed({
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true" :disabled="billableOptionLocked"
>Billable</SelectItem
>
<SelectItem value="true">Billable</SelectItem>
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>

View File

@@ -20,12 +20,9 @@ import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { Tag, Task } from '@/packages/api/src';
const show = defineModel('show', { default: false });
// Organizations with locked billable rates can remove the billable flag, but not set it
const { locked: billableRatesLocked } = useBillableRatesLock();
const saving = ref(false);
const props = defineProps<{
@@ -266,9 +263,7 @@ const showBreakWarning = computed(
<SelectItem value="do-not-update">
Keep current billable status
</SelectItem>
<SelectItem value="billable" :disabled="billableRatesLocked"
>Billable</SelectItem
>
<SelectItem value="billable">Billable</SelectItem>
<SelectItem value="non-billable">Non Billable</SelectItem>
</SelectContent>
</Select>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import { useBillableRatesLock } from './utils/useBillableRatesLock';
const { requestUpgrade } = useBillableRatesLock();
</script>
<template>
<button
type="button"
data-testid="billable_rates_locked_badge"
class="inline-flex items-center space-x-1 rounded border border-border-secondary bg-secondary px-2 py-1 hover:border-border-tertiary hover:bg-tertiary"
@click.prevent.stop="requestUpgrade">
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
<span class="text-xs font-semibold text-text-secondary"><slot>Upgrade</slot></span>
</button>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid';
import { Button } from './Buttons';
import { useBillableRatesLock } from './utils/useBillableRatesLock';
const { requestUpgrade } = useBillableRatesLock();
</script>
<template>
<span class="inline-flex items-center gap-1">
--
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-text-tertiary hover:text-text-primary"
aria-label="Upgrade to unlock billable rates"
data-testid="billable_rates_locked_button"
@click.prevent.stop="requestUpgrade">
<LockClosedIcon />
</Button>
</span>
</template>

View File

@@ -1,139 +0,0 @@
<script setup lang="ts">
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './dialog';
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import { computed, type Component } from 'vue';
import MockupBillableRates from './UpgradeModal/MockupBillableRates.vue';
import MockupEstimates from './UpgradeModal/MockupEstimates.vue';
import MockupGoals from './UpgradeModal/MockupGoals.vue';
import MockupInvoicing from './UpgradeModal/MockupInvoicing.vue';
import MockupPdfReports from './UpgradeModal/MockupPdfReports.vue';
import MockupShareableReports from './UpgradeModal/MockupShareableReports.vue';
import UpgradeCtaFooter from './UpgradeModal/UpgradeCtaFooter.vue';
import { featureIcons } from './UpgradeModal/featureIcons';
import {
professionalFeatures,
type ProfessionalFeatureKey,
type UpgradeFeature,
} from './UpgradeModal/useUpgradeCta';
const show = defineModel<boolean>('show', { default: false });
const props = defineProps<{
// The Professional feature the user tried to use, picks the mock-up and the copy
feature: UpgradeFeature;
}>();
const contents: Record<
UpgradeFeature,
{
mockup: Component;
pill: string;
headline: string;
body: string;
// Feature of the pricing page that is listed first, if the feature is listed there
featureKey?: ProfessionalFeatureKey;
}
> = {
'billable-rates': {
mockup: MockupBillableRates,
pill: 'Unlock billable rates',
headline: 'Turn tracked hours into revenue',
body: 'Upgrade to solidtime Professional to track billable hours',
featureKey: 'rates',
},
estimates: {
mockup: MockupEstimates,
pill: 'Unlock time estimates',
headline: 'Keep every project on budget',
body: 'Upgrade to solidtime Professional to set time estimates',
featureKey: 'estimates',
},
goals: {
mockup: MockupGoals,
pill: 'Unlock unlimited goals',
headline: 'Set a goal for everything that matters',
body: 'Upgrade to solidtime Professional to track unlimited goals',
},
invoicing: {
mockup: MockupInvoicing,
pill: 'Unlock invoicing',
headline: 'Get paid for every tracked hour',
body: 'Upgrade to solidtime Professional to create invoices',
featureKey: 'invoicing',
},
'pdf-reports': {
mockup: MockupPdfReports,
pill: 'Unlock PDF reports',
headline: 'Send reports your clients will read',
body: 'Upgrade to solidtime Professional to export PDF reports',
featureKey: 'pdf',
},
'shareable-reports': {
mockup: MockupShareableReports,
pill: 'Unlock shareable reports',
headline: 'Share live reports with one link',
body: 'Upgrade to solidtime Professional to share reports',
featureKey: 'shared',
},
};
const content = computed(() => contents[props.feature]);
// The feature the user tried to use comes first
const orderedFeatures = computed(() => [
...professionalFeatures.filter((feature) => feature.key === content.value.featureKey),
...professionalFeatures.filter((feature) => feature.key !== content.value.featureKey),
]);
</script>
<template>
<Dialog v-model:open="show">
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
<div class="relative">
<component :is="content.mockup" />
<div
class="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 translate-y-1/2 whitespace-nowrap">
<div
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 dark:border-border-secondary dark:bg-default-background text-xs font-medium text-text-primary shadow-sm">
<LockClosedIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
{{ content.pill }}
</div>
</div>
</div>
<div class="px-8 pt-9 text-center">
<DialogTitle class="text-xl font-semibold tracking-tight text-text-primary">
{{ content.headline }}
</DialogTitle>
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
{{ content.body }}
</DialogDescription>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-9">
<div v-for="item in orderedFeatures" :key="item.key" class="flex gap-3">
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
<component
:is="featureIcons[item.key]"
class="h-3.5 w-3.5 text-text-secondary" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
{{ item.description }}
</p>
</div>
</div>
</div>
<div class="px-8 pt-9 pb-6">
<UpgradeCtaFooter
align="stack"
label="Upgrade to Professional"
class="mx-auto max-w-xs"
@close="show = false" />
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
const previewRows = [
{ name: 'Acme Corp', duration: '42h 30min', cost: '€ 5,100.00' },
{ name: 'Globex', duration: '27h 15min', cost: '€ 3,270.00' },
{ name: 'Initech', duration: '12h 45min', cost: '€ 1,530.00' },
];
</script>
<template>
<MockupFrame :glow-in-light-mode="false">
<div class="px-6 pt-4">
<div
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
<span>Client</span>
<span class="text-right">Duration</span>
<span class="flex items-center justify-end gap-1">
<LockClosedIcon class="h-3 w-3" />
Cost
</span>
</div>
<div
v-for="row in previewRows"
:key="row.name"
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
<span class="text-right tabular-nums text-gray-500 dark:text-white/50">{{
row.duration
}}</span>
<span
class="select-none text-right font-medium text-accent-600 blur-[5px] dark:text-accent-300"
>{{ row.cost }}</span
>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -1,48 +0,0 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
const previewRows = [
{ name: 'Website Redesign', spent: '32h', estimate: '40h', progress: 80, muted: false },
{ name: 'Mobile App', spent: '11h', estimate: '20h', progress: 55, muted: true },
{ name: 'Support Retainer', spent: '6h', estimate: '20h', progress: 30, muted: false },
];
</script>
<template>
<MockupFrame :glow-in-light-mode="false">
<div class="px-6 pt-4">
<div
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
<span>Project</span>
<span class="flex items-center gap-1">
<LockClosedIcon class="h-3 w-3" />
Progress
</span>
</div>
<div
v-for="row in previewRows"
:key="row.name"
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
<div class="flex select-none items-center gap-2.5 blur-[3px]">
<div
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
<div
class="h-full rounded-full"
:class="
row.muted
? 'bg-gray-400 dark:bg-white/40'
: 'bg-accent-500 dark:bg-accent-300'
"
:style="{ width: row.progress + '%' }" />
</div>
<span
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
>{{ row.spent }} / {{ row.estimate }}</span
>
</div>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
withDefaults(
defineProps<{
// Show the blue glow in light mode too, dark mode always has it
glowInLightMode?: boolean;
}>(),
{ glowInLightMode: true }
);
</script>
<template>
<!-- Note: Shared header of the upgrade modal, all mock-ups have the same height -->
<div
class="relative h-[156px] overflow-hidden border-b border-border-tertiary bg-background dark:border-white/10 dark:bg-[#0b1220]">
<!-- Note: Glow rises from behind the divider, around the pill -->
<div
:class="glowInLightMode ? '' : 'hidden dark:block'"
class="pointer-events-none absolute -bottom-32 left-1/2 h-56 w-[30rem] -translate-x-1/2 rounded-full bg-accent-400/25 blur-3xl dark:bg-accent-500/[0.18]" />
<div class="relative h-full" aria-hidden="true">
<slot />
</div>
</div>
</template>

View File

@@ -1,54 +0,0 @@
<script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
// Note: The first goal is the one of the free plan, the others are locked
const previewGoals = [
{
name: 'Billable hours per month',
tracked: '62h',
target: '80h',
progress: 78,
locked: false,
},
{ name: 'Deep work per week', tracked: '14h', target: '20h', progress: 70, locked: true },
{ name: 'Support per week', tracked: '3h', target: '8h', progress: 40, locked: true },
];
</script>
<template>
<MockupFrame :glow-in-light-mode="false">
<div class="px-6 pt-4">
<div
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
<span>Goal</span>
<span>Progress</span>
</div>
<div
v-for="goal in previewGoals"
:key="goal.name"
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
<span class="flex items-center gap-1.5 text-gray-800 dark:text-white/90">
<LockClosedIcon
v-if="goal.locked"
class="h-3 w-3 text-gray-400 dark:text-white/40" />
{{ goal.name }}
</span>
<div
class="flex select-none items-center gap-2.5"
:class="goal.locked ? 'blur-[3px]' : ''">
<div
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
<div
class="h-full rounded-full bg-accent-500 dark:bg-accent-300"
:style="{ width: goal.progress + '%' }" />
</div>
<span
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
>{{ goal.tracked }} / {{ goal.target }}</span
>
</div>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
import MockupFrame from './MockupFrame.vue';
const lineItems = [
{ name: 'Website Redesign', hours: '32h', amount: '€ 3,840.00' },
{ name: 'Mobile App', hours: '18h', amount: '€ 2,160.00' },
];
</script>
<template>
<MockupFrame>
<!-- Note: An invoice created from tracked time, it runs off the bottom edge of the header -->
<div
class="absolute left-1/2 top-5 w-60 -translate-x-1/2 rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
<div class="flex items-start justify-between">
<div>
<p class="text-[11px] font-semibold text-gray-900">Invoice 2026-014</p>
<p class="text-[9px] text-gray-500">Acme Corp · Due November 14</p>
</div>
<span
class="rounded bg-accent-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
>SENT</span
>
</div>
<div class="mt-3 space-y-1 text-[9px] text-gray-600">
<div v-for="item in lineItems" :key="item.name" class="flex justify-between">
<span>{{ item.name }} · {{ item.hours }}</span>
<span class="tabular-nums">{{ item.amount }}</span>
</div>
</div>
<div
class="mt-2 flex justify-between border-t border-gray-200 pt-1.5 text-[10px] font-semibold text-gray-900">
<span>Total</span>
<span class="tabular-nums">€ 6,000.00</span>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
const bars = [45, 70, 55, 85, 65, 35, 50];
</script>
<template>
<!-- Note: A PDF page that runs off the bottom edge of the header -->
<div
class="rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
<div class="flex items-start justify-between">
<div>
<p class="text-[11px] font-semibold text-gray-900">Time report</p>
<p class="text-[9px] text-gray-500">Acme Corp · October 2026</p>
</div>
<span
class="rounded bg-red-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
>PDF</span
>
</div>
<div class="mt-2.5 flex h-7 items-end gap-1">
<div
v-for="(height, index) in bars"
:key="index"
class="flex-1 rounded-sm bg-accent-500/70"
:style="{ height: height + '%' }" />
</div>
<div class="mt-2 space-y-1 text-[9px] text-gray-600">
<div class="flex justify-between">
<span>Website Redesign</span><span class="tabular-nums">32h 15min</span>
</div>
<div class="flex justify-between">
<span>Mobile App</span><span class="tabular-nums">18h 40min</span>
</div>
</div>
</div>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import MockupFrame from './MockupFrame.vue';
import MockupPdfPage from './MockupPdfPage.vue';
</script>
<template>
<MockupFrame>
<!-- Note: The PDF report with two more pages fanned out behind it -->
<div class="absolute left-1/2 top-5 h-full w-56 -translate-x-1/2">
<div
class="absolute inset-x-0 top-3 h-48 origin-bottom -translate-x-10 -rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
<div
class="absolute inset-x-0 top-3 h-48 origin-bottom translate-x-10 rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
<MockupPdfPage class="absolute inset-x-0 top-0" />
</div>
</MockupFrame>
</template>

View File

@@ -1,50 +0,0 @@
<script setup lang="ts">
import { LinkIcon, LockClosedIcon } from '@heroicons/vue/16/solid';
import MockupFrame from './MockupFrame.vue';
const previewRows = [
{ name: 'Website Redesign', duration: '32h 15min' },
{ name: 'Mobile App', duration: '18h 40min' },
];
</script>
<template>
<MockupFrame>
<!-- Note: A browser window showing the shared report, it runs into the bottom edge -->
<div
class="absolute inset-x-10 top-4 -bottom-px rounded-t-lg border border-b-0 border-gray-300/80 bg-white shadow-xl shadow-gray-400/20 dark:border-white/10 dark:bg-white/[0.04] dark:shadow-2xl dark:shadow-black/40">
<div
class="flex items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-white/10">
<div class="flex gap-1">
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
</div>
<div
class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-[11px] text-gray-500 dark:bg-black/30 dark:text-white/50">
<LockClosedIcon class="h-3 w-3 shrink-0 text-gray-400 dark:text-white/30" />
<span class="truncate">app.solidtime.io/shared-report</span>
</div>
<span
class="flex shrink-0 items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-medium text-gray-700 dark:border-transparent dark:bg-white/15 dark:text-white/90">
<LinkIcon class="h-3 w-3" />
Copy link
</span>
</div>
<div class="px-4 pt-2 pb-1.5">
<p class="py-1.5 text-xs font-medium text-gray-900 dark:text-white/90">
Acme Corp · October
</p>
<div
v-for="row in previewRows"
:key="row.name"
class="flex justify-between border-b border-gray-100 py-1 text-xs last:border-b-0 dark:border-white/5">
<span class="text-gray-600 dark:text-white/70">{{ row.name }}</span>
<span class="tabular-nums text-gray-400 dark:text-white/50">{{
row.duration
}}</span>
</div>
</div>
</div>
</MockupFrame>
</template>

View File

@@ -1,78 +0,0 @@
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/16/solid';
import { pricingUrl, useUpgradeCta } from './useUpgradeCta';
const props = withDefaults(
defineProps<{
label?: string;
align?: 'between' | 'end' | 'stack';
showCompare?: boolean;
}>(),
{ label: 'Upgrade to Professional', align: 'between', showCompare: true }
);
const emit = defineEmits<{ close: [] }>();
const { canUpgrade, goToBilling } = useUpgradeCta();
</script>
<template>
<div
:class="
props.align === 'stack'
? 'flex flex-col items-center gap-3'
: 'flex flex-wrap items-center gap-2'
">
<a
v-if="props.align !== 'stack' && props.showCompare"
:href="pricingUrl"
target="_blank"
rel="noopener"
class="mr-auto inline-flex items-center gap-1 text-xs text-text-tertiary transition-colors hover:text-text-primary">
Compare plans
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
</a>
<template v-if="canUpgrade">
<button
v-if="props.align !== 'stack'"
type="button"
class="h-9 whitespace-nowrap rounded-md px-3 text-sm font-medium text-text-secondary transition-colors hover:bg-white/5 hover:text-text-primary"
@click="emit('close')">
Not now
</button>
<button
type="button"
data-testid="upgrade_modal_cta"
class="h-9 whitespace-nowrap rounded-md bg-accent-500 px-4 text-sm font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 transition-colors hover:bg-accent-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="props.align === 'stack' ? 'w-full' : ''"
@click="goToBilling">
{{ props.label }}
</button>
</template>
<template v-else>
<span class="text-xs text-text-tertiary">Only the organization owner can upgrade.</span>
<button
type="button"
class="h-9 rounded-md border border-border-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-white/5"
@click="emit('close')">
Got it
</button>
</template>
<div v-if="props.align === 'stack'" class="flex items-center gap-4 text-xs">
<button
v-if="canUpgrade"
type="button"
class="text-text-tertiary transition-colors hover:text-text-primary"
@click="emit('close')">
Not now
</button>
<a
:href="pricingUrl"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-text-tertiary transition-colors hover:text-text-primary">
Compare plans
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
</a>
</div>
</div>
</template>

View File

@@ -1,19 +0,0 @@
import type { Component } from 'vue';
import {
AdjustmentsHorizontalIcon,
ClockIcon,
CurrencyEuroIcon,
DocumentArrowDownIcon,
DocumentTextIcon,
LinkIcon,
} from '@heroicons/vue/16/solid';
import type { ProfessionalFeatureKey } from './useUpgradeCta';
export const featureIcons: Record<ProfessionalFeatureKey, Component> = {
rates: CurrencyEuroIcon,
invoicing: DocumentTextIcon,
estimates: ClockIcon,
rounding: AdjustmentsHorizontalIcon,
pdf: DocumentArrowDownIcon,
shared: LinkIcon,
};

View File

@@ -1,72 +0,0 @@
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
export interface UpgradeCta {
canUpgrade: ComputedRef<boolean>;
goToBilling: () => void;
}
export const upgradeCtaKey: InjectionKey<UpgradeCta> = Symbol('upgradeCta');
/**
* Shared call to action for the upgrade modal variations: members who can manage billing are
* sent to the billing page, everyone else is asked to contact the organization owner.
*
* The app layout provides who can upgrade and how to get to the billing page. Without a
* provider (e.g. other consumers of this package) nobody can upgrade from the modal.
*/
export function useUpgradeCta(): UpgradeCta {
return inject(upgradeCtaKey, {
canUpgrade: computed(() => false),
goToBilling: () => {},
});
}
// Note: Copy mirrors https://www.solidtime.io/pricing so the gate and the pricing page match.
export const pricingUrl = 'https://www.solidtime.io/pricing';
// Professional features the upgrade modal has a variant for
export type UpgradeFeature =
'billable-rates' | 'estimates' | 'goals' | 'invoicing' | 'pdf-reports' | 'shareable-reports';
export type ProfessionalFeatureKey =
'rates' | 'invoicing' | 'estimates' | 'rounding' | 'pdf' | 'shared';
export type ProfessionalFeature = {
key: ProfessionalFeatureKey;
title: string;
description: string;
};
// Note: Billing and reporting features of the Professional plan on https://www.solidtime.io/pricing
export const professionalFeatures: ProfessionalFeature[] = [
{
key: 'rates',
title: 'Billable rates',
description: 'Set rates per organization, project or member.',
},
{
key: 'invoicing',
title: 'Invoicing',
description: 'Create invoices straight from your tracked time.',
},
{
key: 'estimates',
title: 'Time estimates',
description: 'Budget hours per project and task and see what is left.',
},
{
key: 'rounding',
title: 'Rounding',
description: 'Round time in reports, e.g. to 15 minutes.',
},
{
key: 'pdf',
title: 'PDF reports',
description: 'Export clean reports to send to clients.',
},
{
key: 'shared',
title: 'Shareable reports',
description: 'Share a live report link, no login needed.',
},
];

View File

@@ -21,10 +21,6 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { ActivityPeriod } from './FullCalendar/activityTypes';
export { cn } from './utils/cn';
export { useCssVariable } from './utils/useCssVariable';
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
export type { BillableRatesLock } from './utils/useBillableRatesLock';
export { upgradeCtaKey, useUpgradeCta } from './UpgradeModal/useUpgradeCta';
export type { UpgradeCta, UpgradeFeature } from './UpgradeModal/useUpgradeCta';
import Badge from './Badge.vue';
import Button from './Buttons/Button.vue';
@@ -166,10 +162,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs/index';
import TabBar from './TabBar/TabBar.vue';
import TabBarItem from './TabBar/TabBarItem.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import UpgradeBadge from './UpgradeBadge.vue';
import UpgradeLockedBadge from './UpgradeLockedBadge.vue';
import UpgradeLockedValue from './UpgradeLockedValue.vue';
import UpgradeModal from './UpgradeModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
export {
@@ -313,8 +305,4 @@ export {
TooltipContent,
TooltipProvider,
TooltipTrigger,
UpgradeBadge,
UpgradeLockedBadge,
UpgradeLockedValue,
UpgradeModal,
};

View File

@@ -1,22 +0,0 @@
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
export interface BillableRatesLock {
locked: ComputedRef<boolean>;
requestUpgrade: () => void;
}
export const billableRatesLockKey: InjectionKey<BillableRatesLock> = Symbol('billableRatesLock');
/**
* Whether billable rates are locked behind a plan upgrade for the current organization.
*
* The app layout provides the lock state and opens the upgrade dialog. Without a
* provider (e.g. public report views or other consumers of this package) billable
* rates count as unlocked.
*/
export function useBillableRatesLock(): BillableRatesLock {
return inject(billableRatesLockKey, {
locked: computed(() => false),
requestUpgrade: () => {},
});
}

View File

@@ -98,13 +98,3 @@ export function isAllowedToPerformPremiumAction() {
(isBillingActivated() && isInTrial())
);
}
export function canUseBillableRates() {
const page = usePage<{
billing: {
can_use_billable_rates?: boolean;
} | null;
}>();
return !isBillingActivated() || page.props.billing?.can_use_billable_rates !== false;
}

View File

@@ -422,7 +422,6 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
},
});

View File

@@ -217,7 +217,6 @@ export function useCopyLastWeek(
}
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
if (attempted === 0) return;

View File

@@ -174,7 +174,6 @@ export function useTimesheetCellMutations(
);
} finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}
}

View File

@@ -22,8 +22,6 @@ import {
canCreateInvitations,
} from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import { useQueryClient } from '@tanstack/vue-query';
import { fetchCanCreateMoreGoals } from '@/utils/useGoalsQuery';
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
@@ -68,7 +66,6 @@ const showCreateClientModal = ref(false);
const showCreateTaskModal = ref(false);
const showCreateTagModal = ref(false);
const showCreateGoalModal = ref(false);
const showGoalUpgradeModal = ref(false);
const showInviteMemberModal = ref(false);
const showCreateTimeEntryModal = ref(false);
@@ -106,7 +103,6 @@ const ENTITY_ICONS: Record<string, typeof FolderIcon> = {
};
export function useCommandPalette() {
const queryClient = useQueryClient();
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
const { setActiveState, updateTimer } = currentTimeEntryStore;
@@ -244,13 +240,9 @@ export function useCommandPalette() {
showCreateTagModal.value = true;
}
async function openCreateGoalModal() {
function openCreateGoalModal() {
closePaletteAfterAction();
if (await fetchCanCreateMoreGoals(queryClient)) {
showCreateGoalModal.value = true;
} else {
showGoalUpgradeModal.value = true;
}
showCreateGoalModal.value = true;
}
function openInviteMemberModal() {
@@ -533,7 +525,6 @@ export function useCommandPalette() {
showCreateTaskModal,
showCreateTagModal,
showCreateGoalModal,
showGoalUpgradeModal,
showInviteMemberModal,
showCreateTimeEntryModal,
showProjectSelector,

View File

@@ -261,7 +261,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
stopLiveTimer();
}
}
queryClient.invalidateQueries({ queryKey: ['goals'] });
} else {
throw new Error(
'Failed to fetch current time entry because organization ID is missing.'

View File

@@ -1,10 +1,9 @@
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Goal } from '@/packages/api/src';
import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages';
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
export type GoalArchivedFilter = 'true' | 'false' | 'all';
@@ -20,38 +19,19 @@ export async function fetchAllGoals(
);
}
export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') {
return {
queryKey: ['goals', getCurrentOrganizationId(), archived],
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
const data = await fetchAllGoals(organizationId, archived);
return { data };
},
staleTime: 1000 * 30, // 30 seconds
};
}
/**
* Checks the goal limit outside of the goals page, where the goals are not loaded yet.
* Archived goals count towards the limit, so all goals are counted.
*/
export async function fetchCanCreateMoreGoals(queryClient: QueryClient): Promise<boolean> {
if (goalLimit() === null) {
return true;
}
const { data } = await queryClient.fetchQuery(goalsQueryOptions('all'));
return canCreateMoreGoals(data.length);
}
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient();
const query = useQuery({
...goalsQueryOptions(archived),
queryKey: computed(() => goalsQueryOptions(archived).queryKey),
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds
// Progress is computed server side, keep it fresh while the page is open
refetchInterval: 1000 * 60,
});

Some files were not shown because too many files have changed in this diff Show More