Compare commits

...

13 Commits

Author SHA1 Message Date
Gregor Vostrak
127f1cbb3d Always show the create goal button and open the upgrade modal when the goal limit is reached 2026-10-14 16:58:53 +02:00
Gregor Vostrak
adfd715836 Invalidate goals after time entry and member changes 2026-10-14 16:43:58 +02:00
Constantin Graf
812b3f2f54 Update billing extension to v0.0.11 2026-10-14 15:15:24 +02:00
Constantin Graf
4b25b2adfa Mention all import sources and warn about billable data not being imported on free plan 2026-10-14 15:15:24 +02:00
Constantin Graf
9557296eb0 Skip enabled extensions without vite config in vite module loader 2026-10-14 15:15:24 +02:00
Constantin Graf
c69edc81be Add tests for billable rate restriction in importers and API edge cases 2026-10-14 15:15:24 +02:00
Constantin Graf
902270a189 Only check billable rate restriction if time entry becomes billable 2026-10-14 15:15:24 +02:00
Constantin Graf
f60c9ca457 Move billable rate restriction tests to the endpoint tests of the specific endpoints 2026-10-14 15:15:24 +02:00
Constantin Graf
1c41432389 Enforce billable rate restriction in controllers and importers instead of model hooks 2026-10-14 15:15:24 +02:00
Gregor Vostrak
3197c8c7a0 update invoicing and billing extensions to tagged releases 2026-10-14 15:15:24 +02:00
Gregor Vostrak
b8761dffce load billable rate limits extension branches for billing and invoicing 2026-10-14 15:15:24 +02:00
Gregor Vostrak
ba2b37f264 add billable rate restriction for free organizations 2026-10-14 15:15:24 +02:00
Gregor Vostrak
9e6013eaf1 move upgrade modal to ui package and add trial status and cancel trial ui 2026-10-14 15:15:24 +02:00
107 changed files with 2619 additions and 415 deletions

View File

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

View File

@@ -49,4 +49,9 @@ class Controller extends \App\Http\Controllers\Controller
{ {
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization); 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,10 +81,9 @@ class MemberController extends Controller
{ {
$this->checkPermission($organization, 'members:update', $member); $this->checkPermission($organization, 'members:update', $member);
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) { $oldBillableRate = $member->billable_rate;
if ($request->has('billable_rate') && $this->canUseBillableRates($organization)) {
$member->billable_rate = $request->getBillableRate(); $member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member);
} }
if ($request->has('role') && $member->role !== $request->getRole()->value) { if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole(); $newRole = $request->getRole();
@@ -93,6 +92,10 @@ class MemberController extends Controller
} }
$member->save(); $member->save();
if ($oldBillableRate !== $member->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
return new MemberResource($member); return new MemberResource($member);
} }

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType; use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException;
@@ -30,6 +29,7 @@ use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\LocalizationService; use App\Service\LocalizationService;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport; use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\ReportExport\TimeEntriesDetailedExport;
@@ -229,7 +229,7 @@ class TimeEntryController extends Controller
* *
* @operationId exportTimeEntries * @operationId exportTimeEntries
*/ */
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
{ {
$member = $this->member($organization); $member = $this->member($organization);
/** @var Member|null $memberFilter */ /** @var Member|null $memberFilter */
@@ -247,7 +247,7 @@ class TimeEntryController extends Controller
} }
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -386,7 +386,7 @@ class TimeEntryController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): array
{ {
$member = $this->member($organization); $member = $this->member($organization);
/** @var Member|null $memberFilter */ /** @var Member|null $memberFilter */
@@ -398,7 +398,7 @@ class TimeEntryController extends Controller
} }
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
@@ -436,7 +436,7 @@ class TimeEntryController extends Controller
* @throws NoOutputFileInResponse * @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException * @throws FeatureIsNotAvailableInFreePlanApiException
*/ */
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
{ {
$member = $this->member($organization); $member = $this->member($organization);
/** @var Member|null $memberFilter */ /** @var Member|null $memberFilter */
@@ -453,7 +453,7 @@ class TimeEntryController extends Controller
} }
$debug = $request->getDebug(); $debug = $request->getDebug();
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
@@ -647,6 +647,10 @@ class TimeEntryController extends Controller
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->fill($request->validated()); $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->client()->associate($client);
$timeEntry->user_id = $member->user_id; $timeEntry->user_id = $member->user_id;
$timeEntry->description = $request->input('description') ?? ''; $timeEntry->description = $request->input('description') ?? '';
@@ -714,7 +718,12 @@ class TimeEntryController extends Controller
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null; $task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
} }
$wasBillable = $timeEntry->billable;
$timeEntry->fill($request->validated()); $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) { if ($newMember !== null) {
$timeEntry->member()->associate($newMember); $timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user); $timeEntry->user()->associate($newMember->user);
@@ -772,6 +781,7 @@ class TimeEntryController extends Controller
->get(); ->get();
$changes = $request->validated('changes'); $changes = $request->validated('changes');
$canUseBillableRates = $this->canUseBillableRates($organization);
if ($request->has('changes.description')) { if ($request->has('changes.description')) {
$changes['description'] = $request->input('changes.description') ?? ''; $changes['description'] = $request->input('changes.description') ?? '';
@@ -837,8 +847,13 @@ class TimeEntryController extends Controller
} }
$oldProject = $timeEntry->project; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;
$wasBillable = $timeEntry->billable;
$timeEntry->fill($changes); $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) { if ($newMember !== null) {
$timeEntry->member()->associate($newMember); $timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id; $timeEntry->user_id = $newMember->user_id;

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -13,6 +14,24 @@ use Illuminate\Database\Eloquent\Builder;
class BillableRateService 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 public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
{ {
TimeEntry::query() TimeEntry::query()

View File

@@ -63,4 +63,13 @@ class BillingContract
{ {
return false; 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

@@ -128,7 +128,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if (! in_array($record['Billable'], ['Yes', 'No'], true)) { if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value'); throw new ImportException('Invalid billable value');
} }
$timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
} }
if ($isBreak) { if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above) // Breaks can not be billable or belong to a project/task (already skipped above)

View File

@@ -74,9 +74,16 @@ abstract class DefaultImporter implements ImporterContract
protected BillableRateService $billableRateService; 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 public function init(Organization $organization): void
{ {
$this->organization = $organization; $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) { $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization); return $builder->belongsToOrganization($this->organization);
@@ -99,7 +106,11 @@ abstract class DefaultImporter implements ImporterContract
'string', 'string',
'in:placeholder', '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) { $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id); return $builder->where('organization_id', $this->organization->id);
@@ -126,6 +137,10 @@ abstract class DefaultImporter implements ImporterContract
if ($project->billable_rate === 0) { if ($project->billable_rate === 0) {
$project->billable_rate = null; $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 { $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
/** @var Builder<ProjectMember> $builder */ /** @var Builder<ProjectMember> $builder */
@@ -140,6 +155,9 @@ abstract class DefaultImporter implements ImporterContract
if ($projectMember->billable_rate === 0) { if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null; $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 { $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
@@ -181,7 +199,6 @@ abstract class DefaultImporter implements ImporterContract
$this->timeEntriesCreated = 0; $this->timeEntriesCreated = 0;
$this->colorService = app(ColorService::class); $this->colorService = app(ColorService::class);
$this->timezoneService = app(TimezoneService::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)) { if (! in_array($record['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value'); throw new ImportException('Invalid billable value');
} }
$timeEntry->billable = $record['billable'] === 'true'; $timeEntry->billable = $this->canUseBillableRates && $record['billable'] === 'true';
$timeEntry->tags = $this->getTags($record['tags']); $timeEntry->tags = $this->getTags($record['tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
try { try {

View File

@@ -115,7 +115,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) { if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value'); throw new ImportException('Invalid billable value');
} }
$timeEntry->billable = $record['Billable?'] === 'Yes'; $timeEntry->billable = $this->canUseBillableRates && $record['Billable?'] === 'Yes';
$timeEntry->tags = []; $timeEntry->tags = [];
$timeEntry->is_imported = true; $timeEntry->is_imported = true;

View File

@@ -268,7 +268,7 @@ class SolidtimeImporter extends DefaultImporter
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) { if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value'); throw new ImportException('Invalid billable value');
} }
$timeEntry->billable = $timeEntryRow['billable'] === 'true'; $timeEntry->billable = $this->canUseBillableRates && $timeEntryRow['billable'] === 'true';
// The type column does not exist in old exports // The type column does not exist in old exports
if (($timeEntryRow['type'] ?? '') !== '') { if (($timeEntryRow['type'] ?? '') !== '') {
$type = TimeEntryType::tryFrom($timeEntryRow['type']); $type = TimeEntryType::tryFrom($timeEntryRow['type']);

View File

@@ -118,7 +118,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
if (! in_array($record['Billable'], ['Yes', 'No'], true)) { if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value'); throw new ImportException('Invalid billable value');
} }
$timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
try { try {

View File

@@ -342,6 +342,89 @@ 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 }) => { test('test that editing a goal via the modal works', async ({ page, ctx }) => {
const goal = await createGoalViaApi(ctx, { const goal = await createGoalViaApi(ctx, {
name: 'Old goal name', 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(); await expect(table).toBeVisible();
// First click = desc (highest first), null rates last // First click = desc (highest first), null rates last
const billableHeader = table.getByText('Billable Rate').first(); const billableHeader = table.getByRole('button', { name: /Billable Rate/ });
await billableHeader.click(); await billableHeader.click();
await expect(billableHeader.locator('svg')).toBeVisible(); await expect(billableHeader.locator('svg')).toBeVisible();
let names = await getTableRowNames(table); let names = await getTableRowNames(table);

14
e2e/utils/billing.ts Normal file
View File

@@ -0,0 +1,14 @@
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": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "v0.0.9" "ref": "v0.0.11"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
@@ -9,7 +9,7 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.10" "ref": "v0.0.12"
}, },
"Auditing": { "Auditing": {
"repository": "solidtime-io/extension-auditing", "repository": "solidtime-io/extension-auditing",

View File

@@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import { existsSync, readFileSync } from 'node:fs'; import { enabledExtensions } from './playwright/config';
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -7,23 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
*/ */
// require('dotenv').config(); // require('dotenv').config();
/** /*
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions * 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, * 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. * 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 * 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. * whole repository, including node_modules, vendor and nested worktrees, to find the specs.
*/ */

View File

@@ -1,3 +1,23 @@
import { existsSync, readFileSync } from 'node:fs';
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test'; 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 MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
export const TEST_USER_PASSWORD = 'amazingpassword123'; 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,137 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid'; import { XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { computed } from 'vue'; import { computed } from 'vue';
import { import { isBillingActivated, isBlocked } from '@/utils/billing';
daysLeftInTrial,
isBillingActivated,
isBlocked,
isFreePlan,
isInTrial,
} from '@/utils/billing';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { canManageBilling } from '@/utils/permissions'; import { canManageBilling } from '@/utils/permissions';
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
const hideBlockedBanner = useSessionStorage( const hideBlockedBanner = useSessionStorage(
'showBlockedBanner-' + getCurrentOrganizationId(), 'showBlockedBanner-' + getCurrentOrganizationId(),
false false
); );
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value); 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> </script>
<template> <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 <div
v-if="showBlockedBanner" v-if="showBlockedBanner"
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary"> class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
@@ -159,33 +43,6 @@ const showBlackFridayBanner = computed(() => {
</div> </div>
</MainContainer> </MainContainer>
</div> </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> </template>
<style scoped></style> <style scoped></style>

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <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 MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done; 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> </script>
<template> <template>
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
</li> </li>
</ol> </ol>
<div class="px-4 space-x-1"> <div class="px-4 space-x-1">
<Badge v-if="project?.billable_rate"> <UpgradeLockedBadge v-if="billableRatesLocked"
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
{{ billableRateFormatted }} {{ billableRateFormatted }}
/ h / h
</Badge> </Badge>
<Badge v-if="project?.is_billable && !project?.billable_rate"> <Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
Default Rate Default Rate
</Badge> </Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </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 { useQueryClient } from '@tanstack/vue-query';
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser'; import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue'; import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
import type { ExportFormat } from '@/types/reporting'; import type { ExportFormat } from '@/types/reporting';
import { useNotificationsStore } from '@/utils/notification'; import { useNotificationsStore } from '@/utils/notification';
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue'; import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
@@ -253,9 +253,7 @@ async function downloadExport(format: ExportFormat) {
<ReportingExportModal <ReportingExportModal
v-model:show="showExportModal" v-model:show="showExportModal"
:export-url="exportUrl"></ReportingExportModal> :export-url="exportUrl"></ReportingExportModal>
<UpgradeModal v-model:show="showPremiumModal"> <UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
<strong>PDF Reports</strong> are only available in solidtime Professional.
</UpgradeModal>
<MainContainer <MainContainer
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center"> 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"> <div class="flex items-center space-x-3 sm:space-x-6">

View File

@@ -5,7 +5,12 @@ import { useNotificationsStore } from '@/utils/notification';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { Field, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldLabel } from '@/packages/ui/src/field';
import { DocumentIcon } from '@heroicons/vue/24/solid'; import { DocumentIcon } from '@heroicons/vue/24/solid';
import { ArrowDownOnSquareIcon, InformationCircleIcon } from '@heroicons/vue/24/outline'; import {
ArrowDownOnSquareIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from '@heroicons/vue/24/outline';
import { canUseBillableRates } from '@/utils/billing';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { ImportReport, ImportType } from '@/packages/api/src'; import type { ImportReport, ImportType } from '@/packages/api/src';
@@ -21,6 +26,8 @@ const { addNotification } = useNotificationsStore();
const loading = ref(false); const loading = ref(false);
const billableRatesLocked = computed(() => !canUseBillableRates());
onMounted(async () => { onMounted(async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (organizationId) { if (organizationId) {
@@ -155,8 +162,19 @@ const showResultModal = ref(false);
<div class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3"> <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" /> <InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
<p class="flex-1"> <p class="flex-1">
Import existing data from Toggl, Clockify or a different solidtime instance. Import existing data from Toggl, Clockify, Harvest, generic CSV files or a
Please select the type of data you want to import and follow the instructions. 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.
</p> </p>
</div> </div>
</Card> </Card>

View File

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

View File

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

View File

@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
const TimeFormat = z.enum(['12-hours', '24-hours']); const TimeFormat = z.enum(['12-hours', '24-hours']);
const OrganizationResource = z const OrganizationResource = z
.object({ .object({
can_use_billable_rates: z.boolean().optional(),
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
is_personal: z.boolean(), is_personal: z.boolean(),
@@ -5069,6 +5070,45 @@ 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', method: 'get',
path: '/v1/public/reports', path: '/v1/public/reports',

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue'; import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
@@ -64,6 +65,7 @@ watch(show, (value) => {
}); });
const editableTimeEntry = ref<TimeEntry | null>(null); const editableTimeEntry = ref<TimeEntry | null>(null);
const { locked: billableRatesLocked } = useBillableRatesLock();
watch( watch(
() => props.timeEntry, () => props.timeEntry,
@@ -134,6 +136,11 @@ 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({ const billableProxy = computed({
get: () => get: () =>
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false', editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
@@ -245,7 +252,9 @@ const typeProxy = computed({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="true">Billable</SelectItem> <SelectItem value="true" :disabled="billableOptionLocked"
>Billable</SelectItem
>
<SelectItem value="false">Non Billable</SelectItem> <SelectItem value="false">Non Billable</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

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

View File

@@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { LockClosedIcon } from '@heroicons/vue/20/solid'; import { LockClosedIcon } from '@heroicons/vue/20/solid';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue'; import UpgradeModal from './UpgradeModal.vue';
import type { UpgradeFeature } from './UpgradeModal/useUpgradeCta';
import { ref } from '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); const showUpgradeModal = ref(false);
</script> </script>
<template> <template>
<UpgradeModal v-model:show="showUpgradeModal"> <UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
<slot>
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
</slot>
</UpgradeModal>
<button <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" 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"> @click.prevent="showUpgradeModal = true">
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon> <LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,72 @@
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,6 +21,10 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { ActivityPeriod } from './FullCalendar/activityTypes'; export type { ActivityPeriod } from './FullCalendar/activityTypes';
export { cn } from './utils/cn'; export { cn } from './utils/cn';
export { useCssVariable } from './utils/useCssVariable'; 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 Badge from './Badge.vue';
import Button from './Buttons/Button.vue'; import Button from './Buttons/Button.vue';
@@ -162,6 +166,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs/index';
import TabBar from './TabBar/TabBar.vue'; import TabBar from './TabBar/TabBar.vue';
import TabBarItem from './TabBar/TabBarItem.vue'; import TabBarItem from './TabBar/TabBarItem.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.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'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
export { export {
@@ -305,4 +313,8 @@ export {
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
UpgradeBadge,
UpgradeLockedBadge,
UpgradeLockedValue,
UpgradeModal,
}; };

View File

@@ -0,0 +1,22 @@
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,3 +98,13 @@ export function isAllowedToPerformPremiumAction() {
(isBillingActivated() && isInTrial()) (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,6 +422,7 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query'; import { useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Goal } from '@/packages/api/src'; import type { Goal } from '@/packages/api/src';
import { computed } from 'vue'; import { computed } from 'vue';
import { fetchAllPages } from '@/utils/fetchAllPages'; import { fetchAllPages } from '@/utils/fetchAllPages';
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
export type GoalArchivedFilter = 'true' | 'false' | 'all'; export type GoalArchivedFilter = 'true' | 'false' | 'all';
@@ -19,19 +20,38 @@ export async function fetchAllGoals(
); );
} }
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') { export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') {
const queryClient = useQueryClient(); return {
queryKey: ['goals', getCurrentOrganizationId(), archived],
const query = useQuery({
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
queryFn: async () => { queryFn: async () => {
const organizationId = getCurrentOrganizationId(); const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization'); if (!organizationId) throw new Error('No organization');
const data = await fetchAllGoals(organizationId, archived); const data = await fetchAllGoals(organizationId, archived);
return { data }; return { data };
}, },
enabled: () => !!getCurrentOrganizationId(),
staleTime: 1000 * 30, // 30 seconds 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(),
// Progress is computed server side, keep it fresh while the page is open // Progress is computed server side, keep it fresh while the page is open
refetchInterval: 1000 * 60, refetchInterval: 1000 * 60,
}); });

View File

@@ -36,6 +36,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -58,6 +59,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -105,6 +107,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -127,6 +130,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });
@@ -152,6 +156,7 @@ export function useTimeEntriesMutations() {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] }); queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
queryClient.invalidateQueries({ queryKey: ['goals'] });
}, },
}); });

View File

@@ -90,6 +90,7 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('getGoalLimit')->andReturn(null); $mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
} }
@@ -102,6 +103,18 @@ abstract class TestCase extends BaseTestCase
// Core has no goal limit, the limit of the free plan is implemented in the billing extension // Core has no goal limit, the limit of the free plan is implemented in the billing extension
$mock->shouldReceive('getGoalLimit')->andReturn(null); $mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
}
protected function actAsOrganizationWithoutBillableRates(): void
{
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasSubscription')->andReturn(false);
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(false);
}); });
} }
} }

View File

@@ -300,4 +300,20 @@ class ChartEndpointTest extends EndpointTestAbstract
// Assert // Assert
$response->assertOk(); $response->assertOk();
} }
public function test_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'charts:view:own',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
} }

View File

@@ -1155,4 +1155,56 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
'message' => 'The given user is not a placeholder', 'message' => 'The given user is not a placeholder',
]); ]);
} }
public function test_member_update_ignores_billable_rate_but_updates_role_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([
'billable_rate' => 10000,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'billable_rate' => 20000,
'role' => Role::Manager->value,
]);
// Assert
$response->assertOk();
$member->refresh();
$this->assertSame(10000, $member->billable_rate);
$this->assertSame(Role::Manager->value, $member->role);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_member_update_does_not_remove_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'billable_rate' => null,
]);
// Assert
$response->assertOk();
$this->assertSame(10000, $member->refresh()->billable_rate);
}
} }

View File

@@ -9,6 +9,7 @@ use App\Events\AfterCreateOrganization;
use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\OrganizationController;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\IpLookup\IpLookupResponseDto; use App\Service\IpLookup\IpLookupResponseDto;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
@@ -567,4 +568,85 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
'id' => $data->organization->getKey(), 'id' => $data->organization->getKey(),
]); ]);
} }
public function test_organization_update_ignores_billable_rate_and_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->billable_rate = 10000;
$data->organization->employees_can_see_billable_rates = false;
$data->organization->save();
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => 20000,
]);
$responseRemoveRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => null,
]);
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Renamed organization',
'employees_can_see_billable_rates' => true,
]);
// Assert
$responseRate->assertOk();
$responseRemoveRate->assertOk();
$responseVisibility->assertOk();
$responseVisibility->assertJsonPath('data.employees_can_see_billable_rates', false);
$data->organization->refresh();
$this->assertSame('Renamed organization', $data->organization->name);
$this->assertSame(10000, $data->organization->billable_rate);
$this->assertFalse($data->organization->employees_can_see_billable_rates);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_can_remove_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->employees_can_see_billable_rates = true;
$data->organization->save();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'employees_can_see_billable_rates' => false,
]);
// Assert
$response->assertOk();
$this->assertFalse($data->organization->refresh()->employees_can_see_billable_rates);
}
public function test_organization_exposes_billable_rate_eligibility_for_api_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'organizations:view',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
// Assert
$response->assertOk();
$response->assertJsonPath('data.can_use_billable_rates', false);
}
} }

View File

@@ -1426,6 +1426,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_endpoint_keeps_billable_rate_if_it_is_not_part_of_the_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
]);
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Renamed',
'color' => $project->color,
'client_id' => null,
'is_billable' => $project->is_billable,
]);
// Assert
$response->assertStatus(200);
$project->refresh();
$this->assertSame('Renamed', $project->name);
$this->assertSame(10000, $project->billable_rate);
}
public function test_update_endpoint_can_archive_a_project(): void public function test_update_endpoint_can_archive_a_project(): void
{ {
// Arrange // Arrange
@@ -1597,4 +1624,174 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
'id' => $projectMember->getKey(), 'id' => $projectMember->getKey(),
]); ]);
} }
public function test_project_store_ignores_billable_flag_and_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.is_billable', false);
$response->assertJsonPath('data.billable_rate', null);
$project = Project::query()->findOrFail($response->json('data.id'));
$this->assertFalse($project->is_billable);
$this->assertNull($project->billable_rate);
}
public function test_project_store_sets_billable_flag_and_rate_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.is_billable', true);
$response->assertJsonPath('data.billable_rate', 10000);
}
public function test_project_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$billableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
$removedBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
$nonBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $billableProject->getKey()]), [
'name' => 'Still billable',
'color' => $billableProject->color,
'client_id' => null,
'is_billable' => true,
]);
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $removedBillableProject->getKey()]), [
'name' => 'No longer billable',
'color' => $removedBillableProject->color,
'client_id' => null,
'is_billable' => false,
]);
$responseSet = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $nonBillableProject->getKey()]), [
'name' => 'Not billable',
'color' => $nonBillableProject->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$responseKeep->assertOk();
$responseKeep->assertJsonPath('data.is_billable', true);
$responseRemove->assertOk();
$responseRemove->assertJsonPath('data.is_billable', false);
$responseSet->assertOk();
$responseSet->assertJsonPath('data.is_billable', false);
$this->assertTrue($billableProject->refresh()->is_billable);
$this->assertFalse($removedBillableProject->refresh()->is_billable);
$nonBillableProject->refresh();
$this->assertFalse($nonBillableProject->is_billable);
$this->assertSame('Not billable', $nonBillableProject->name);
}
public function test_project_update_can_enable_billable_flag_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$response->assertOk();
$this->assertTrue($project->refresh()->is_billable);
}
public function test_project_update_keeps_existing_billable_rate_and_time_entry_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
'is_billable' => true,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Changed rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 20000,
]);
// Note: Organizations that can not use billable rates do not receive the rate of the project, so clients send `null` back
$responseNull = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Hidden rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => null,
]);
$responseOmitted = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Omitted rate',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
]);
// Assert
$responseChange->assertOk();
$responseNull->assertOk();
$responseOmitted->assertOk();
$project->refresh();
$this->assertSame('Omitted rate', $project->name);
$this->assertSame(10000, $project->billable_rate);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
} }

View File

@@ -501,4 +501,40 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
'id' => $projectMember->getKey(), 'id' => $projectMember->getKey(),
]); ]);
} }
public function test_project_member_store_and_update_ignore_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:create',
'project-members:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseStore = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
'member_id' => $otherMember->getKey(),
'billable_rate' => 10000,
]);
$responseUpdate = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
'billable_rate' => 20000,
]);
// Assert
$responseStore->assertCreated();
$responseStore->assertJsonPath('data.billable_rate', null);
$responseUpdate->assertOk();
$responseUpdate->assertJsonPath('data.billable_rate', 10000);
$this->assertSame(10000, $projectMember->refresh()->billable_rate);
$this->assertDatabaseHas(ProjectMember::class, [
'member_id' => $otherMember->getKey(),
'billable_rate' => null,
]);
}
} }

View File

@@ -23,6 +23,7 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\BillingContract;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -35,6 +36,7 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Testing\Fluent\AssertableJson; use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesClass;
use Ramsey\Uuid\Type\Time; use Ramsey\Uuid\Type\Time;
use TiMacDonald\Log\LogEntry; use TiMacDonald\Log\LogEntry;
@@ -6096,4 +6098,291 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
'billable_rate' => null, 'billable_rate' => null,
]); ]);
} }
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($start, 3600)->create([
'billable' => true,
'billable_rate' => 10000,
]);
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
]));
// Assert
$response->assertSuccessful();
$response->assertJsonPath('data.seconds', 3600);
$response->assertJsonPath('data.cost', null);
}
public function test_time_entry_store_creates_non_billable_time_entry_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->billable_rate = 10000;
$data->organization->save();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', false);
$timeEntry = TimeEntry::query()->findOrFail($response->json('data.id'));
$this->assertFalse($timeEntry->billable);
$this->assertNull($timeEntry->billable_rate);
}
public function test_time_entry_store_creates_billable_time_entry_if_organization_can_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', true);
}
public function test_time_entry_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(7), 3600)->create();
$removedBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Still billable',
]);
$responseRemove = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $removedBillableTimeEntry->getKey()]), [
'billable' => false,
]);
$responseSet = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Not billable',
]);
// Assert
$responseKeep->assertOk();
$responseKeep->assertJsonPath('data.billable', true);
$responseRemove->assertOk();
$responseRemove->assertJsonPath('data.billable', false);
$responseSet->assertOk();
$responseSet->assertJsonPath('data.billable', false);
$this->assertTrue($billableTimeEntry->refresh()->billable);
$this->assertSame('Still billable', $billableTimeEntry->description);
$this->assertFalse($removedBillableTimeEntry->refresh()->billable);
$nonBillableTimeEntry->refresh();
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertSame('Not billable', $nonBillableTimeEntry->description);
}
public function test_time_entry_update_multiple_keeps_existing_billable_flags_but_does_not_set_new_ones_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$data->organization->billable_rate = 10000;
$data->organization->save();
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
'changes' => [
'billable' => true,
'description' => 'Mass update',
],
]);
// Assert
$response->assertOk();
$response->assertExactJson([
'success' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
'error' => [],
]);
$billableTimeEntry->refresh();
$this->assertTrue($billableTimeEntry->billable);
$this->assertSame('Mass update', $billableTimeEntry->description);
$nonBillableTimeEntry->refresh();
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertNull($nonBillableTimeEntry->billable_rate);
$this->assertSame('Mass update', $nonBillableTimeEntry->description);
}
public function test_time_entry_update_multiple_can_remove_billable_flag_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [$timeEntry->getKey()],
'changes' => [
'billable' => false,
],
]);
// Assert
$response->assertOk();
$response->assertExactJson([
'success' => [$timeEntry->getKey()],
'error' => [],
]);
$timeEntry->refresh();
$this->assertFalse($timeEntry->billable);
$this->assertNull($timeEntry->billable_rate);
}
public function test_time_entry_update_with_project_change_does_not_make_time_entry_billable_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->billable(10000)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->isPublic()->billable(20000)->create();
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->billable()
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
$this->actAsOrganizationWithoutBillableRates();
Passport::actingAs($data->user);
// Act
$responseBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
'project_id' => $otherProject->getKey(),
'billable' => true,
]);
$responseNonBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
'project_id' => $otherProject->getKey(),
'billable' => true,
]);
// Assert
$responseBillable->assertOk();
$responseNonBillable->assertOk();
$billableTimeEntry->refresh();
$this->assertSame($otherProject->getKey(), $billableTimeEntry->project_id);
$this->assertTrue($billableTimeEntry->billable);
$this->assertSame(20000, $billableTimeEntry->billable_rate);
$nonBillableTimeEntry->refresh();
$this->assertSame($otherProject->getKey(), $nonBillableTimeEntry->project_id);
$this->assertFalse($nonBillableTimeEntry->billable);
$this->assertNull($nonBillableTimeEntry->billable_rate);
}
public function test_time_entry_store_does_not_check_billable_rates_for_non_billable_time_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$this->mockBillingWithoutBillableRateCheck();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => false,
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertCreated();
$response->assertJsonPath('data.billable', false);
}
public function test_time_entry_update_does_not_check_billable_rates_if_time_entry_does_not_become_billable(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
$this->mockBillingWithoutBillableRateCheck();
Passport::actingAs($data->user);
// Act
$responseNonBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
'description' => 'Still not billable',
]);
$responseBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
'billable' => true,
'description' => 'Still billable',
]);
// Assert
$responseNonBillable->assertOk();
$responseNonBillable->assertJsonPath('data.billable', false);
$responseBillable->assertOk();
$responseBillable->assertJsonPath('data.billable', true);
}
/**
* Billing that fails the test if it is asked whether the organization can use billable rates
*/
private function mockBillingWithoutBillableRateCheck(): void
{
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasSubscription')->andReturn(true);
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldNotReceive('canUseBillableRates');
});
}
} }

View File

@@ -36,6 +36,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(1); $mock->shouldReceive('getGoalLimit')->andReturn(1);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -49,6 +50,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.trial_until', null) ->where('billing.trial_until', null)
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.goal_limit', 1) ->where('billing.goal_limit', 1)
->where('billing.can_use_billable_rates', true)
); );
} }
@@ -64,6 +66,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil); $mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('getGoalLimit')->andReturn(null); $mock->shouldReceive('getGoalLimit')->andReturn(null);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -77,6 +80,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.trial_until', $trialUntil->toIso8601ZuluString()) ->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.goal_limit', null) ->where('billing.goal_limit', null)
->where('billing.can_use_billable_rates', true)
); );
} }
} }

View File

@@ -157,4 +157,26 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
Task::query()->where('project_id', $project->id)->pluck('name')->all(), Task::query()->where('project_id', $project->id)->pluck('name')->all(),
); );
} }
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $projects);
foreach ($projects as $project) {
$this->assertFalse($project->is_billable);
$this->assertNull($project->billable_rate);
}
}
} }

View File

@@ -256,4 +256,26 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
} }
$this->fail(); $this->fail();
} }
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $timeEntries);
foreach ($timeEntries as $timeEntry) {
$this->assertFalse($timeEntry->billable);
$this->assertNull($timeEntry->billable_rate);
}
}
} }

View File

@@ -82,4 +82,26 @@ class GenericProjectsImporterTest extends ImporterTestAbstract
$this->assertSame(3, $report->projectsCreated); $this->assertSame(3, $report->projectsCreated);
$this->assertSame(2, $report->clientsCreated); $this->assertSame(2, $report->clientsCreated);
} }
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(3, $projects);
foreach ($projects as $project) {
$this->assertFalse($project->is_billable);
$this->assertNull($project->billable_rate);
}
}
} }

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