mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-21 20:23:19 +01:00
Compare commits
23 Commits
feature/bi
...
v0.22.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93eb93f1dc | ||
|
|
c8a300e1b6 | ||
|
|
eed654b9c8 | ||
|
|
68833a5125 | ||
|
|
735f3b8d39 | ||
|
|
9caf911795 | ||
|
|
c55afd234e | ||
|
|
20cc5f9e5b | ||
|
|
2df7a2845e | ||
|
|
510cb10cb9 | ||
|
|
b46f015754 | ||
|
|
fc8985f513 | ||
|
|
812b3f2f54 | ||
|
|
4b25b2adfa | ||
|
|
9557296eb0 | ||
|
|
c69edc81be | ||
|
|
902270a189 | ||
|
|
f60c9ca457 | ||
|
|
1c41432389 | ||
|
|
3197c8c7a0 | ||
|
|
b8761dffce | ||
|
|
ba2b37f264 | ||
|
|
9e6013eaf1 |
14
.github/workflows/generate-api-docs.yml
vendored
14
.github/workflows/generate-api-docs.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -57,10 +58,9 @@ jobs:
|
||||
name: api-docs.json
|
||||
path: build/api-docs.json
|
||||
|
||||
- name: "Download Fastfront CLI"
|
||||
run: curl https://fastfront-cli.s3.fr-par.scw.cloud/fastfront-cli.phar -o fastfront-cli.phar
|
||||
|
||||
- name: "Deploy with Fastfront"
|
||||
run: php fastfront-cli.phar deploy 9beab6cf-f459-446b-85f1-38ec007cf457 ./build
|
||||
env:
|
||||
FASTFRONT_API_KEY: ${{ secrets.FASTFRONT_API_DOCS_API_KEY }}
|
||||
- name: "Deploy with FastFront"
|
||||
uses: fastfront-io/deploy-action@v0.0.1
|
||||
with:
|
||||
folder: './build'
|
||||
environment: ${{ vars.FASTFRONT_API_DOCS_STAGE }}
|
||||
api_key: ${{ secrets.FASTFRONT_API_DOCS_STAGE_KEY }}
|
||||
|
||||
@@ -6,7 +6,19 @@ Contributions are greatly apprecited, please make sure to read the rules and vis
|
||||
|
||||
### Issues for Bugs, Discussions for Feature requests
|
||||
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancements are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
|
||||
### Do not use AI-generated text in issues, discussions and PR descriptions
|
||||
|
||||
We are human, so please write in natural language in your issues, discussions, and PR descriptions.
|
||||
Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
|
||||
|
||||
### Before you contribute, ask yourself what value you add
|
||||
|
||||
We much prefer reading your thoughts on features or issues you encountered than reading through hundreds of lines of code changes produced by your agent.
|
||||
Consider that the maintainers of this project have the same access to LLMs and more knowledge about the project itself.
|
||||
Prompting a feature into existence without further consideration or discussion is mostly a burden for maintainers.
|
||||
If we want to, we can burn the tokens ourselves.
|
||||
|
||||
### Only work on approved issues
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -30,8 +30,16 @@ If you do not want to self-host solidtime or try it out you can sign up for [sol
|
||||
|
||||
## Issues & Feature Requests
|
||||
|
||||
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
||||
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||
If you
|
||||
* find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
||||
* have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||
|
||||
|
||||
> **Do not use AI-generated text in issues, discussions and PR descriptions.**
|
||||
>
|
||||
> We are human, so please write in natural language in your issues, discussions, and PR descriptions.
|
||||
> Don't be a meatproxy. We will ban people who repeatedly violate this rule from further communication on our repositories.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -154,12 +154,12 @@ class ChartController extends Controller
|
||||
*
|
||||
* @response array{value: int, currency: string}
|
||||
*/
|
||||
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
||||
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService, BillableRateService $billableRateService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$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) {
|
||||
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||
}
|
||||
|
||||
@@ -49,4 +49,9 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||
}
|
||||
|
||||
protected function canUseBillableRates(Organization $organization): bool
|
||||
{
|
||||
return app(BillingContract::class)->canUseBillableRates($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,9 @@ class MemberController extends Controller
|
||||
{
|
||||
$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();
|
||||
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
}
|
||||
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
||||
$newRole = $request->getRole();
|
||||
@@ -93,6 +92,10 @@ class MemberController extends Controller
|
||||
}
|
||||
$member->save();
|
||||
|
||||
if ($oldBillableRate !== $member->billable_rate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
}
|
||||
|
||||
return new MemberResource($member);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||
@@ -28,11 +27,11 @@ class OrganizationController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function show(Organization $organization): OrganizationResource
|
||||
public function show(Organization $organization, BillableRateService $billableRateService): OrganizationResource
|
||||
{
|
||||
$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);
|
||||
}
|
||||
@@ -47,6 +46,7 @@ class OrganizationController extends Controller
|
||||
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:update');
|
||||
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||
|
||||
if ($request->getName() !== null) {
|
||||
$organization->name = $request->getName();
|
||||
@@ -54,7 +54,8 @@ class OrganizationController extends Controller
|
||||
if ($request->getCurrency() !== null) {
|
||||
$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();
|
||||
}
|
||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||
@@ -81,14 +82,13 @@ class OrganizationController extends Controller
|
||||
if ($request->getBreaksEnabled() !== null) {
|
||||
$organization->breaks_enabled = $request->getBreaksEnabled();
|
||||
}
|
||||
$hasBillableRate = $request->has('billable_rate');
|
||||
if ($hasBillableRate) {
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
if ($request->has('billable_rate') && $canUseBillableRates) {
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
$organization->save();
|
||||
|
||||
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
|
||||
if ($oldBillableRate !== $organization->billable_rate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||
@@ -39,7 +38,7 @@ class ProjectController extends Controller
|
||||
*
|
||||
* @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');
|
||||
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||
@@ -63,7 +62,7 @@ class ProjectController extends Controller
|
||||
->orderBy('id')
|
||||
->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);
|
||||
}
|
||||
@@ -97,11 +96,12 @@ class ProjectController extends Controller
|
||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:create');
|
||||
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||
$project = new Project;
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->is_billable = $canUseBillableRates && $request->boolean('is_billable');
|
||||
$project->billable_rate = $canUseBillableRates ? $request->getBillableRate() : null;
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->is_public = $request->getIsPublic();
|
||||
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
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||
$project->name = $request->input('name');
|
||||
$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')) {
|
||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
@@ -137,14 +141,16 @@ class ProjectController extends Controller
|
||||
}
|
||||
$oldBillableRate = $project->billable_rate;
|
||||
$clientIdChanged = false;
|
||||
if ($request->has('billable_rate') && $canUseBillableRates) {
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
if ($project->client_id !== $request->input('client_id')) {
|
||||
$project->client_id = $request->input('client_id');
|
||||
$clientIdChanged = true;
|
||||
}
|
||||
$project->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
if ($oldBillableRate !== $project->billable_rate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
}
|
||||
if ($clientIdChanged) {
|
||||
|
||||
@@ -73,13 +73,13 @@ class ProjectMemberController extends Controller
|
||||
}
|
||||
|
||||
$projectMember = new ProjectMember;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->billable_rate = $this->canUseBillableRates($organization) ? $request->getBillableRate() : null;
|
||||
$projectMember->member()->associate($member);
|
||||
$projectMember->user()->associate($member->user);
|
||||
$projectMember->project()->associate($project);
|
||||
$projectMember->save();
|
||||
|
||||
if ($request->getBillableRate() !== null) {
|
||||
if ($projectMember->billable_rate !== null) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
@@ -97,10 +97,12 @@ class ProjectMemberController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$oldBillableRate = $projectMember->billable_rate;
|
||||
if ($this->canUseBillableRates($organization)) {
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
}
|
||||
$projectMember->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
if ($oldBillableRate !== $projectMember->billable_rate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\Role;
|
||||
use App\Enums\TimeEntryType;
|
||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||
@@ -30,6 +29,7 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\LocalizationService;
|
||||
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
||||
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
||||
@@ -229,7 +229,7 @@ class TimeEntryController extends Controller
|
||||
*
|
||||
* @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);
|
||||
/** @var Member|null $memberFilter */
|
||||
@@ -247,7 +247,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$user = $this->user();
|
||||
$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;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -386,7 +386,7 @@ class TimeEntryController extends Controller
|
||||
*
|
||||
* @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);
|
||||
/** @var Member|null $memberFilter */
|
||||
@@ -398,7 +398,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$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();
|
||||
$group2Type = $request->getSubGroup();
|
||||
@@ -436,7 +436,7 @@ class TimeEntryController extends Controller
|
||||
* @throws NoOutputFileInResponse
|
||||
* @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);
|
||||
/** @var Member|null $memberFilter */
|
||||
@@ -453,7 +453,7 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$debug = $request->getDebug();
|
||||
$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();
|
||||
$subGroup = $request->getSubGroup();
|
||||
@@ -647,6 +647,10 @@ class TimeEntryController extends Controller
|
||||
|
||||
$timeEntry = new TimeEntry;
|
||||
$timeEntry->fill($request->validated());
|
||||
// Organizations that can not use billable rates can not create billable time entries
|
||||
if ($timeEntry->billable && ! $this->canUseBillableRates($organization)) {
|
||||
$timeEntry->billable = false;
|
||||
}
|
||||
$timeEntry->client()->associate($client);
|
||||
$timeEntry->user_id = $member->user_id;
|
||||
$timeEntry->description = $request->input('description') ?? '';
|
||||
@@ -714,7 +718,12 @@ class TimeEntryController extends Controller
|
||||
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
||||
}
|
||||
|
||||
$wasBillable = $timeEntry->billable;
|
||||
$timeEntry->fill($request->validated());
|
||||
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||
if ($timeEntry->billable && ! $wasBillable && ! $this->canUseBillableRates($organization)) {
|
||||
$timeEntry->billable = false;
|
||||
}
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user()->associate($newMember->user);
|
||||
@@ -772,6 +781,7 @@ class TimeEntryController extends Controller
|
||||
->get();
|
||||
|
||||
$changes = $request->validated('changes');
|
||||
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||
|
||||
if ($request->has('changes.description')) {
|
||||
$changes['description'] = $request->input('changes.description') ?? '';
|
||||
@@ -837,8 +847,13 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
$oldProject = $timeEntry->project;
|
||||
$oldTask = $timeEntry->task;
|
||||
$wasBillable = $timeEntry->billable;
|
||||
|
||||
$timeEntry->fill($changes);
|
||||
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||
if (! $wasBillable && ! $canUseBillableRates) {
|
||||
$timeEntry->billable = false;
|
||||
}
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user_id = $newMember->user_id;
|
||||
|
||||
@@ -60,6 +60,7 @@ class HandleInertiaRequests extends Middleware
|
||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
||||
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
@@ -68,7 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
],
|
||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
||||
// 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([
|
||||
'nullable',
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\CurrencyService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -51,6 +52,8 @@ class OrganizationResource extends BaseResource
|
||||
'is_personal' => $this->resource->personal_team,
|
||||
/** Billable rate in cents per hour */
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** Whether the organization's plan allows billable flags and rates */
|
||||
'can_use_billable_rates' => app(BillingContract::class)->canUseBillableRates($this->resource),
|
||||
/** Can members of the organization with role "employee" see the billable rates */
|
||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
@@ -13,6 +14,24 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BillableRateService
|
||||
{
|
||||
public function __construct(
|
||||
private BillingContract $billing,
|
||||
) {}
|
||||
|
||||
public function canUseBillableRates(Organization $organization): bool
|
||||
{
|
||||
return $this->billing->canUseBillableRates($organization);
|
||||
}
|
||||
|
||||
public function canSeeBillableRates(Organization $organization, Member $member): bool
|
||||
{
|
||||
if (! $this->canUseBillableRates($organization)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $member->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
}
|
||||
|
||||
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
|
||||
{
|
||||
TimeEntry::query()
|
||||
|
||||
@@ -63,4 +63,13 @@ class BillingContract
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization can set and see billable rates
|
||||
* Without a billing system every organization can use billable rates
|
||||
*/
|
||||
public function canUseBillableRates(Organization $organization): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
||||
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
|
||||
}
|
||||
if ($isBreak) {
|
||||
// Breaks can not be billable or belong to a project/task (already skipped above)
|
||||
|
||||
@@ -74,9 +74,16 @@ abstract class DefaultImporter implements ImporterContract
|
||||
|
||||
protected BillableRateService $billableRateService;
|
||||
|
||||
/**
|
||||
* Organizations that can not use billable rates import everything without billable flags and rates
|
||||
*/
|
||||
protected bool $canUseBillableRates;
|
||||
|
||||
public function init(Organization $organization): void
|
||||
{
|
||||
$this->organization = $organization;
|
||||
$this->billableRateService = app(BillableRateService::class);
|
||||
$this->canUseBillableRates = $this->billableRateService->canUseBillableRates($organization);
|
||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->belongsToOrganization($this->organization);
|
||||
@@ -99,7 +106,11 @@ abstract class DefaultImporter implements ImporterContract
|
||||
'string',
|
||||
'in:placeholder',
|
||||
],
|
||||
]);
|
||||
], beforeSave: function (Member $member): void {
|
||||
if (! $this->canUseBillableRates) {
|
||||
$member->billable_rate = null;
|
||||
}
|
||||
});
|
||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->where('organization_id', $this->organization->id);
|
||||
@@ -126,6 +137,10 @@ abstract class DefaultImporter implements ImporterContract
|
||||
if ($project->billable_rate === 0) {
|
||||
$project->billable_rate = null;
|
||||
}
|
||||
if (! $this->canUseBillableRates) {
|
||||
$project->is_billable = false;
|
||||
$project->billable_rate = null;
|
||||
}
|
||||
});
|
||||
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
|
||||
/** @var Builder<ProjectMember> $builder */
|
||||
@@ -140,6 +155,9 @@ abstract class DefaultImporter implements ImporterContract
|
||||
if ($projectMember->billable_rate === 0) {
|
||||
$projectMember->billable_rate = null;
|
||||
}
|
||||
if (! $this->canUseBillableRates) {
|
||||
$projectMember->billable_rate = null;
|
||||
}
|
||||
});
|
||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
@@ -181,7 +199,6 @@ abstract class DefaultImporter implements ImporterContract
|
||||
$this->timeEntriesCreated = 0;
|
||||
$this->colorService = app(ColorService::class);
|
||||
$this->timezoneService = app(TimezoneService::class);
|
||||
$this->billableRateService = app(BillableRateService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,7 +134,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
||||
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
||||
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->is_imported = true;
|
||||
try {
|
||||
|
||||
@@ -115,7 +115,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
||||
throw new ImportException('Invalid billable value');
|
||||
}
|
||||
$timeEntry->billable = $record['Billable?'] === 'Yes';
|
||||
$timeEntry->billable = $this->canUseBillableRates && $record['Billable?'] === 'Yes';
|
||||
$timeEntry->tags = [];
|
||||
$timeEntry->is_imported = true;
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
||||
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
|
||||
if (($timeEntryRow['type'] ?? '') !== '') {
|
||||
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
|
||||
|
||||
@@ -118,7 +118,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||
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->is_imported = true;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG PHP_VERSION=8.3
|
||||
ARG FRANKENPHP_VERSION=1.11
|
||||
ARG PHP_VERSION=8.3.36
|
||||
ARG FRANKENPHP_VERSION=1.13
|
||||
ARG COMPOSER_VERSION=2.8
|
||||
ARG APP_ENV
|
||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||
|
||||
@@ -7,6 +7,7 @@ realpath_cache_ttl = 720
|
||||
memory_limit = 256M
|
||||
max_input_time = 5
|
||||
register_argc_argv = 0
|
||||
session.upload_progress.enabled = 0
|
||||
date.timezone = ${TZ:-UTC}
|
||||
|
||||
[Opcache]
|
||||
|
||||
@@ -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 }) => {
|
||||
const goal = await createGoalViaApi(ctx, {
|
||||
name: 'Old goal name',
|
||||
|
||||
@@ -884,7 +884,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// First click = desc (highest first), null rates last
|
||||
const billableHeader = table.getByText('Billable Rate').first();
|
||||
const billableHeader = table.getByRole('button', { name: /Billable Rate/ });
|
||||
await billableHeader.click();
|
||||
await expect(billableHeader.locator('svg')).toBeVisible();
|
||||
let names = await getTableRowNames(table);
|
||||
|
||||
14
e2e/utils/billing.ts
Normal file
14
e2e/utils/billing.ts
Normal 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);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.9"
|
||||
"ref": "v0.0.11"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "v0.0.4"
|
||||
"ref": "v0.0.6"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.10"
|
||||
"ref": "v0.0.12"
|
||||
},
|
||||
"Auditing": {
|
||||
"repository": "solidtime-io/extension-auditing",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { enabledExtensions } from './playwright/config';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -7,23 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
/*
|
||||
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
|
||||
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
|
||||
* a checked out but disabled extension would fail its specs otherwise.
|
||||
*/
|
||||
function enabledExtensions(): string[] {
|
||||
const statusesFile = './modules_statuses.json';
|
||||
if (!existsSync(statusesFile)) {
|
||||
return [];
|
||||
}
|
||||
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
||||
return Object.entries(statuses)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
|
||||
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
|
||||
*/
|
||||
|
||||
@@ -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 MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
||||
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
||||
|
||||
/**
|
||||
* Extensions that are enabled in modules_statuses.json (php artisan module:enable <Name>)
|
||||
*/
|
||||
export function enabledExtensions(): string[] {
|
||||
const statusesFile = './modules_statuses.json';
|
||||
if (!existsSync(statusesFile)) {
|
||||
return [];
|
||||
}
|
||||
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
||||
return Object.entries(statuses)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
export function isExtensionEnabled(name: string): boolean {
|
||||
return enabledExtensions().includes(name);
|
||||
}
|
||||
|
||||
@@ -1,137 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
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 { computed } from 'vue';
|
||||
import {
|
||||
daysLeftInTrial,
|
||||
isBillingActivated,
|
||||
isBlocked,
|
||||
isFreePlan,
|
||||
isInTrial,
|
||||
} from '@/utils/billing';
|
||||
import { isBillingActivated, isBlocked } from '@/utils/billing';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
|
||||
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
|
||||
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
|
||||
const hideBlockedBanner = useSessionStorage(
|
||||
'showBlockedBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
||||
const hideFreeUpgradeBanner = useSessionStorage(
|
||||
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showFreeUpgradeBanner = computed(
|
||||
() =>
|
||||
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
|
||||
);
|
||||
const hideBlackFridayBanner = useSessionStorage(
|
||||
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
|
||||
const showBlackFridayBanner = computed(() => {
|
||||
if (hideBlackFridayBanner.value) {
|
||||
return false;
|
||||
}
|
||||
const today = new Date();
|
||||
const blackFriday = new Date(2024, 10, 30);
|
||||
return today < blackFriday;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBlackFridayBanner"
|
||||
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="#FF37AD"
|
||||
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
|
||||
<path
|
||||
fill="#FCC954"
|
||||
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
|
||||
<path
|
||||
fill="#2890E9"
|
||||
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
|
||||
<path
|
||||
fill="#F0A420"
|
||||
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
|
||||
<path
|
||||
fill="#FCC954"
|
||||
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
|
||||
<path
|
||||
fill="#F0A420"
|
||||
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
|
||||
<path
|
||||
fill="#2E6AC9"
|
||||
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
|
||||
<path
|
||||
fill="#2890E9"
|
||||
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
|
||||
<path
|
||||
fill="#FF37AD"
|
||||
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
|
||||
<path
|
||||
fill="#2890E9"
|
||||
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
|
||||
</svg>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
<strong>BLACK FRIDAY SALE!</strong> Use the code
|
||||
<strong>BLACKFRIDAY</strong> at checkout and get
|
||||
<strong>30% off</strong> the solidtime yearly plan.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideBlackFridayBanner = true">
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showTrialBanner"
|
||||
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
Your trial expires in {{ daysLeftInTrial() }} days.
|
||||
</span>
|
||||
<span class="hidden md:inline">
|
||||
To continue using all features & support the development of solidtime,
|
||||
please upgrade your plan.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideTrialBanner = true">
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBlockedBanner"
|
||||
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
@@ -159,33 +43,6 @@ const showBlackFridayBanner = computed(() => {
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFreeUpgradeBanner"
|
||||
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium"> You are currently using the Free Plan. </span>
|
||||
<span class="hidden md:inline">
|
||||
To unlock all premium features & support the development of solidtime,
|
||||
please upgrade your plan.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
86
resources/js/Components/Billing/CancelTrialModal.vue
Normal file
86
resources/js/Components/Billing/CancelTrialModal.vue
Normal 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>
|
||||
56
resources/js/Components/Billing/SidebarPlanStatus.vue
Normal file
56
resources/js/Components/Billing/SidebarPlanStatus.vue
Normal 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>
|
||||
79
resources/js/Components/Billing/TrialUpgradeModal.vue
Normal file
79
resources/js/Components/Billing/TrialUpgradeModal.vue
Normal 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>
|
||||
@@ -29,6 +29,7 @@ import ClientCreateModal from '@/Components/Common/Client/ClientCreateModal.vue'
|
||||
import TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||
|
||||
@@ -53,6 +54,7 @@ const {
|
||||
showCreateTaskModal,
|
||||
showCreateTagModal,
|
||||
showCreateGoalModal,
|
||||
showGoalUpgradeModal,
|
||||
showInviteMemberModal,
|
||||
showCreateTimeEntryModal,
|
||||
showProjectSelector,
|
||||
@@ -185,6 +187,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
||||
|
||||
<!-- Goal Create Modal -->
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
||||
<UpgradeModal v-model:show="showGoalUpgradeModal" feature="goals" />
|
||||
|
||||
<!-- Member Invite Modal -->
|
||||
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
||||
|
||||
43
resources/js/Components/Common/Goal/GoalCreateButton.test.ts
Normal file
43
resources/js/Components/Common/Goal/GoalCreateButton.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
resources/js/Components/Common/Goal/GoalCreateButton.vue
Normal file
31
resources/js/Components/Common/Goal/GoalCreateButton.vue
Normal 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>
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { Target } from '@lucide/vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { ref } from 'vue';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
||||
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { LoadingSpinner } from '@/packages/ui/src';
|
||||
import { isGoalsExtensionActivated } from '@/utils/billing';
|
||||
@@ -18,7 +15,6 @@ const props = defineProps<{
|
||||
showingArchived: boolean;
|
||||
}>();
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showTargetColumn = isGoalsExtensionActivated();
|
||||
// Every column gets a share of the free space so the columns are spread evenly over the table
|
||||
const gridTemplateColumns = [
|
||||
@@ -33,7 +29,6 @@ const gridTemplateColumns = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
||||
@@ -51,14 +46,11 @@ const gridTemplateColumns = [
|
||||
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
||||
Set yourself a target and track your progress every day, week or month.
|
||||
</p>
|
||||
<SecondaryButton
|
||||
v-if="
|
||||
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
|
||||
"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateGoalModal = true"
|
||||
<GoalCreateButton
|
||||
v-if="canCreateGoals() && !props.showingArchived"
|
||||
:can-create-more-goals="props.canCreateMoreGoals"
|
||||
>Create your first goal
|
||||
</SecondaryButton>
|
||||
</GoalCreateButton>
|
||||
</div>
|
||||
<template v-for="goal in props.goals" :key="goal.id">
|
||||
<GoalTableRow
|
||||
|
||||
@@ -43,6 +43,7 @@ const deleteMutation = useMutation({
|
||||
onSuccess: () => {
|
||||
close();
|
||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
|
||||
const { updateMember } = useMembersStore();
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
@@ -71,6 +72,8 @@ function saveWithChecks() {
|
||||
}
|
||||
}
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
||||
|
||||
onMounted(() => {
|
||||
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
|
||||
<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="getOrganizationCurrencyString()"
|
||||
name="memberBillableRate" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Select v-model="billableRateSelect">
|
||||
<SelectTrigger id="billableRateType">
|
||||
<SelectValue />
|
||||
|
||||
@@ -52,6 +52,7 @@ async function submit() {
|
||||
'There was an error merging the members.',
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
show.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { Member, Organization } from '@/packages/api/src';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
||||
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
||||
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
|
||||
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
|
||||
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||
import { formatCents } from '../../../packages/ui/src/utils/money';
|
||||
import {
|
||||
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
|
||||
const userHasValidMailAddress = computed(() => {
|
||||
return !props.member.email.endsWith('@solidtime-import.test');
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,8 +93,9 @@ const userHasValidMailAddress = computed(() => {
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||
{{ capitalizeFirstLetter(member.role) }}
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||
<span v-if="member.billable_rate">
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
<span v-else-if="member.billable_rate">
|
||||
{{
|
||||
formatCents(
|
||||
member.billable_rate,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||
import type { Project } from '@/packages/api/src';
|
||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||
@@ -15,10 +16,11 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
import { useProjectsStore } from '@/utils/useProjects';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
|
||||
@@ -78,6 +80,8 @@ const billableRateInfo = computed(() => {
|
||||
});
|
||||
|
||||
const showEditProjectModal = ref(false);
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,13 +104,15 @@ const showEditProjectModal = ref(false);
|
||||
</span>
|
||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||
</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">
|
||||
{{ client?.name }}
|
||||
</div>
|
||||
<div v-else class="text-text-tertiary">No client</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">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
@@ -119,7 +125,9 @@ const showEditProjectModal = ref(false);
|
||||
<div v-else class="text-text-tertiary">--</div>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"
|
||||
feature="estimates"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="project.estimated_time"
|
||||
:estimated="project.estimated_time"
|
||||
@@ -128,8 +136,9 @@ const showEditProjectModal = ref(false);
|
||||
</div>
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||
class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||
<span v-else class="text-text-tertiary">--</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { ProjectMember } from '@/packages/api/src';
|
||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
@@ -10,6 +11,7 @@ import { capitalizeFirstLetter } from '@/utils/format';
|
||||
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
projectMember: ProjectMember;
|
||||
@@ -33,6 +35,8 @@ const member = computed(() => {
|
||||
return members.value.find((member) => member.id === props.projectMember.member_id);
|
||||
});
|
||||
const showEditModal = ref(false);
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +51,9 @@ const showEditModal = ref(false);
|
||||
{{ member?.name }}
|
||||
</span>
|
||||
</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" />
|
||||
<span v-else>
|
||||
{{
|
||||
projectMember.billable_rate
|
||||
? formatCents(
|
||||
@@ -59,6 +65,7 @@ const showEditModal = ref(false);
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
||||
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'
|
||||
import { h, ref } from 'vue';
|
||||
import type { CreateReportBodyProperties } from '@/packages/api/src';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import { canCreateReports } from '@/utils/permissions';
|
||||
defineProps<{
|
||||
reportProperties: CreateReportBodyProperties;
|
||||
@@ -30,9 +30,7 @@ function onSaveReportClick() {
|
||||
<ReportCreateModal
|
||||
v-model:show="showCreateReportModal"
|
||||
:properties="reportProperties"></ReportCreateModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>Sharable Reports</strong> is only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" feature="shareable-reports" />
|
||||
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
||||
>Save Report</SecondaryButton
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { ExportFormat } from '@/types/reporting';
|
||||
import { ref } from 'vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
download: (format: ExportFormat) => Promise<void>;
|
||||
@@ -48,9 +48,7 @@ function triggerDownload(format: ExportFormat) {
|
||||
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
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 { Check, Coffee } from '@lucide/vue';
|
||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||
@@ -12,6 +13,7 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
|
||||
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
@@ -35,6 +37,8 @@ const roundingMinutes = defineModel<number>('roundingMinutes', { required: true
|
||||
const startDate = defineModel<string>('startDate', { required: true });
|
||||
const endDate = defineModel<string>('endDate', { required: true });
|
||||
|
||||
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
@@ -141,7 +145,17 @@ async function createTag(name: string) {
|
||||
</template>
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ArrowDownTrayIcon,
|
||||
@@ -30,7 +32,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
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 { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
||||
@@ -214,10 +217,13 @@ const showExportModal = ref(false);
|
||||
const exportUrl = ref<string | null>(null);
|
||||
const showCreateReportModal = ref(false);
|
||||
const showPremiumModal = ref(false);
|
||||
// Note: The premium modal is shared by the PDF export and saving a report
|
||||
const premiumFeature = ref<UpgradeFeature>('pdf-reports');
|
||||
const exportLoading = ref(false);
|
||||
|
||||
function triggerExport(format: ExportFormat) {
|
||||
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
||||
premiumFeature.value = 'pdf-reports';
|
||||
showPremiumModal.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -231,6 +237,7 @@ function onSaveReportClick() {
|
||||
if (isAllowedToPerformPremiumAction()) {
|
||||
showCreateReportModal.value = true;
|
||||
} else {
|
||||
premiumFeature.value = 'shareable-reports';
|
||||
showPremiumModal.value = true;
|
||||
}
|
||||
}
|
||||
@@ -290,6 +297,8 @@ const tableData = computed(() => {
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -299,9 +308,7 @@ const tableData = computed(() => {
|
||||
<ReportCreateModal
|
||||
v-model:show="showCreateReportModal"
|
||||
:properties="reportProperties"></ReportCreateModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
This feature is only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" :feature="premiumFeature" />
|
||||
<MainContainer
|
||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||
@@ -447,6 +454,8 @@ const tableData = computed(() => {
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="justify-end pr-6 flex items-center font-medium">
|
||||
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||
<template v-else>
|
||||
{{
|
||||
aggregatedTableTimeEntries.cost
|
||||
? formatCents(
|
||||
@@ -458,6 +467,7 @@ const tableData = computed(() => {
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { ref, inject, type ComputedRef } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
@@ -27,6 +29,8 @@ const props = defineProps<{
|
||||
const expanded = ref(false);
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,6 +57,8 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
}}
|
||||
</div>
|
||||
<div v-if="showCost" class="justify-end pr-6 flex items-center">
|
||||
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||
<template v-else>
|
||||
{{
|
||||
entry.cost
|
||||
? formatCents(
|
||||
@@ -64,6 +70,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,7 @@ defineProps<{
|
||||
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
||||
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
||||
<dd class="text-xl text-text-primary pt-1 font-medium">
|
||||
{{ value ?? '--' }}
|
||||
<slot>{{ value ?? '--' }}</slot>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
|
||||
import { ref, inject, type ComputedRef } from 'vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
@@ -55,7 +55,9 @@ const showTaskEditModal = ref(false);
|
||||
<span v-else> -- </span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"
|
||||
feature="estimates"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="task.estimated_time"
|
||||
:estimated="task.estimated_time"
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
||||
queryKey: ['totalWeeklyBillableAmount', organizationId],
|
||||
queryFn: () => {
|
||||
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
|
||||
},
|
||||
});
|
||||
},
|
||||
enabled: computed(() => !!organizationId.value),
|
||||
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
@@ -270,7 +274,11 @@ const option = computed(() => {
|
||||
)
|
||||
: '--'
|
||||
" />
|
||||
<StatCard v-if="billableRatesLocked" title="Billable Amount">
|
||||
<UpgradeLockedValue />
|
||||
</StatCard>
|
||||
<StatCard
|
||||
v-else
|
||||
title="Billable Amount"
|
||||
:value="
|
||||
totalWeeklyBillableAmount
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||
import {
|
||||
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
|
||||
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
||||
);
|
||||
});
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -190,6 +194,8 @@ const showBillableRate = computed(() => {
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="justify-end pr-6 flex items-center font-medium">
|
||||
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||
<template v-else>
|
||||
{{
|
||||
aggregatedTableTimeEntries.cost
|
||||
? formatCents(
|
||||
@@ -201,6 +207,7 @@ const showBillableRate = computed(() => {
|
||||
)
|
||||
: '--'
|
||||
}}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, usePage } from '@inertiajs/vue3';
|
||||
import { Head, router, usePage } from '@inertiajs/vue3';
|
||||
import Banner from '@/Components/Banner.vue';
|
||||
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
@@ -23,7 +23,7 @@ import { PanelLeft, Target } from '@lucide/vue';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import { nextTick, onMounted, provide, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, provide, ref } from 'vue';
|
||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||
import { initializeStores } from '@/utils/init';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
@@ -38,12 +38,16 @@ import {
|
||||
canViewReport,
|
||||
canViewTags,
|
||||
} 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 { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||
import SidebarPlanStatus from '@/Components/Billing/SidebarPlanStatus.vue';
|
||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||
import { useTheme } from '@/utils/theme';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
@@ -89,6 +93,19 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
||||
|
||||
provide('organization', organization);
|
||||
|
||||
const showBillableRatesUpgradeModal = ref(false);
|
||||
provide(billableRatesLockKey, {
|
||||
locked: computed(() => !canUseBillableRates()),
|
||||
requestUpgrade: () => {
|
||||
showBillableRatesUpgradeModal.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
provide(upgradeCtaKey, {
|
||||
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
|
||||
goToBilling: () => router.visit('/billing'),
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
useTheme();
|
||||
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
||||
@@ -306,6 +323,7 @@ const page = usePage<{
|
||||
</div>
|
||||
<div class="justify-self-end">
|
||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||
<SidebarPlanStatus v-if="isBillingActivated() && canManageBilling()" />
|
||||
<ul
|
||||
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
||||
<UserSettingsIcon></UserSettingsIcon>
|
||||
@@ -384,4 +402,5 @@ const page = usePage<{
|
||||
<NotificationContainer></NotificationContainer>
|
||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||
<CommandPaletteProvider></CommandPaletteProvider>
|
||||
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
|
||||
</template>
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { Target } from '@lucide/vue';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
||||
import { canCreateMoreGoals } from '@/utils/billing';
|
||||
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
||||
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
||||
|
||||
const showCreateGoalModal = ref(false);
|
||||
const activeTab = ref('active');
|
||||
const showArchived = computed(() => activeTab.value === 'archived');
|
||||
|
||||
@@ -25,7 +21,6 @@ const visibleGoals = computed(() =>
|
||||
);
|
||||
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
||||
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
||||
const limit = goalLimit();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,19 +37,9 @@ const limit = goalLimit();
|
||||
</TabBarItem>
|
||||
</TabBar>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
|
||||
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
|
||||
available in solidtime Professional.
|
||||
</UpgradeBadge>
|
||||
<SecondaryButton
|
||||
v-if="canCreateGoals() && canCreateMore"
|
||||
:icon="PlusIcon"
|
||||
@click="showCreateGoalModal = true"
|
||||
>Create Goal
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
||||
<GoalCreateButton
|
||||
v-if="canCreateGoals()"
|
||||
:can-create-more-goals="canCreateMore"></GoalCreateButton>
|
||||
</MainContainer>
|
||||
<GoalTable
|
||||
:goals="visibleGoals"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||
import { canUseBillableRates } from '@/utils/billing';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
|
||||
return task.project_id === projectId && task.is_done;
|
||||
});
|
||||
});
|
||||
|
||||
// Note: The page sits above the app layout that provides the lock, so it can not inject it
|
||||
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
|
||||
</li>
|
||||
</ol>
|
||||
<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 }}
|
||||
/ h
|
||||
</Badge>
|
||||
<Badge v-if="project?.is_billable && !project?.billable_rate">
|
||||
<Badge
|
||||
v-if="
|
||||
project?.is_billable && !project?.billable_rate && !billableRatesLocked
|
||||
">
|
||||
Default Rate
|
||||
</Badge>
|
||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import type { ExportFormat } from '@/types/reporting';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
@@ -253,9 +253,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
<ReportingExportModal
|
||||
v-model:show="showExportModal"
|
||||
:export-url="exportUrl"></ReportingExportModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||
<MainContainer
|
||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||
|
||||
@@ -5,7 +5,12 @@ import { useNotificationsStore } from '@/utils/notification';
|
||||
import { api } from '@/packages/api/src';
|
||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
||||
import { DocumentIcon } from '@heroicons/vue/24/solid';
|
||||
import { ArrowDownOnSquareIcon, 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 type { ImportReport, ImportType } from '@/packages/api/src';
|
||||
@@ -21,6 +26,8 @@ const { addNotification } = useNotificationsStore();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||
|
||||
onMounted(async () => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
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">
|
||||
<InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
|
||||
<p class="flex-1">
|
||||
Import existing data from Toggl, Clockify or a different solidtime instance.
|
||||
Please select the type of data you want to import and follow the instructions.
|
||||
Import existing data from Toggl, Clockify, Harvest, generic CSV files or a
|
||||
different solidtime instance. Please select the type of data you want to import
|
||||
and follow the instructions.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="billableRatesLocked"
|
||||
class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3 border-t border-card-background-separator">
|
||||
<ExclamationTriangleIcon class="h-5 min-w-0 w-5 text-yellow-500" />
|
||||
<p class="flex-1">
|
||||
Billable rates and billable status will not be imported, since they are not
|
||||
available in the free plan. All projects and time entries will be imported as
|
||||
non-billable.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -10,10 +10,12 @@ import { storeToRefs } from 'pinia';
|
||||
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
|
||||
const store = useOrganizationStore();
|
||||
const { fetchOrganization, updateOrganization } = store;
|
||||
const { organization } = storeToRefs(store);
|
||||
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
const organizationBody = ref<UpdateOrganizationBody>({
|
||||
name: '',
|
||||
@@ -75,9 +77,8 @@ function checkForConfirmationModal() {
|
||||
<Checkbox
|
||||
v-if="organization"
|
||||
id="organizationShowBillableRatesToEmployees"
|
||||
v-model:checked="
|
||||
organizationBody.employees_can_see_billable_rates
|
||||
"></Checkbox>
|
||||
v-model:checked="organizationBody.employees_can_see_billable_rates"
|
||||
:disabled="billableRatesLocked"></Checkbox>
|
||||
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
||||
>Show Billable Rates to Employees</FieldLabel
|
||||
>
|
||||
@@ -85,7 +86,10 @@ function checkForConfirmationModal() {
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
|
||||
>Upgrade</PrimaryButton
|
||||
>
|
||||
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
51
resources/js/Pages/Teams/Partials/OrganizationTrial.vue
Normal file
51
resources/js/Pages/Teams/Partials/OrganizationTrial.vue
Normal 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>
|
||||
@@ -8,6 +8,9 @@ import type { Permissions } from '@/types/jetstream';
|
||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||
import OrganizationTrial from '@/Pages/Teams/Partials/OrganizationTrial.vue';
|
||||
import { isBillingActivated, isInTrial } from '@/utils/billing';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
@@ -52,6 +55,11 @@ onMounted(async () => {
|
||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||
|
||||
<template v-if="isBillingActivated() && isInTrial() && canManageBilling()">
|
||||
<SectionBorder />
|
||||
<OrganizationTrial />
|
||||
</template>
|
||||
|
||||
<template v-if="permissions.canDeleteTeam">
|
||||
<SectionBorder />
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
|
||||
@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
|
||||
const TimeFormat = z.enum(['12-hours', '24-hours']);
|
||||
const OrganizationResource = z
|
||||
.object({
|
||||
can_use_billable_rates: z.boolean().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
is_personal: z.boolean(),
|
||||
@@ -5069,6 +5070,45 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/trial/cancel',
|
||||
alias: 'cancelTrial',
|
||||
description: `The organization switches to the free plan right away.`,
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/public/reports',
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '..';
|
||||
import UpgradeLockedField from './UpgradeLockedField.vue';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
@@ -20,6 +22,8 @@ const model = defineModel<number | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const { locked } = useBillableRatesLock();
|
||||
|
||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||
useFocus(billableRateInput, { initialValue: props.focus });
|
||||
|
||||
@@ -30,7 +34,9 @@ function formatValue(modelValue: number | null) {
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<UpgradeLockedField v-if="locked" />
|
||||
<NumberField
|
||||
v-else
|
||||
:id="name"
|
||||
ref="billableRateInput"
|
||||
:model-value="formatValue(model)"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -10,7 +11,13 @@ import {
|
||||
} from '@/packages/ui/src/tooltip';
|
||||
const active = defineModel<boolean>({ default: false });
|
||||
const emit = defineEmits(['changed']);
|
||||
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||
function toggleBillable() {
|
||||
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||
if (billableRatesLocked.value && !active.value) {
|
||||
requestUpgrade();
|
||||
return;
|
||||
}
|
||||
active.value = !active.value;
|
||||
emit('changed', active.value);
|
||||
}
|
||||
@@ -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(() => {
|
||||
if (active.value) {
|
||||
|
||||
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal file
23
resources/js/packages/ui/src/Input/UpgradeLockedField.vue
Normal 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>
|
||||
@@ -10,21 +10,27 @@ import {
|
||||
} from '@/packages/ui/src/tooltip';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||
|
||||
const props = defineProps<{
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
}>();
|
||||
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
type RateType = 'default-rate' | 'custom-rate';
|
||||
|
||||
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
||||
const rateType = ref<RateType>('default-rate');
|
||||
const originallyBillable = ref(false);
|
||||
const billableFlagLocked = computed(() => billableRatesLocked.value && !originallyBillable.value);
|
||||
|
||||
const billableRate = defineModel<number | null>('billableRate');
|
||||
const isBillable = defineModel<boolean>('isBillable');
|
||||
|
||||
onMounted(() => {
|
||||
originallyBillable.value = isBillable.value === true;
|
||||
if (isBillable.value === true) {
|
||||
billableDefault.value = 'billable';
|
||||
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
||||
@@ -66,6 +72,9 @@ const displayedRate = computed({
|
||||
});
|
||||
|
||||
const billableDescription = computed(() => {
|
||||
if (billableFlagLocked.value) {
|
||||
return 'Your organization’s plan does not allow enabling billable projects.';
|
||||
}
|
||||
if (billableDefault.value === 'non-billable') {
|
||||
return 'New time entries for this project will not be marked billable by default.';
|
||||
}
|
||||
@@ -84,14 +93,19 @@ const emit = defineEmits(['submit']);
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="non-billable">Non-billable</SelectItem>
|
||||
<SelectItem value="billable">Billable</SelectItem>
|
||||
<SelectItem value="billable" :disabled="billableFlagLocked">Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<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">
|
||||
<SelectTrigger id="billableRateType">
|
||||
<SelectValue />
|
||||
|
||||
@@ -16,12 +16,14 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -189,7 +191,9 @@ const billableProxy = computed({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="true" :disabled="billableRatesLocked"
|
||||
>Billable</SelectItem
|
||||
>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
@@ -64,6 +65,7 @@ watch(show, (value) => {
|
||||
});
|
||||
|
||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
|
||||
watch(
|
||||
() => 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({
|
||||
get: () =>
|
||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||
@@ -245,7 +252,9 @@ const typeProxy = computed({
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Billable</SelectItem>
|
||||
<SelectItem value="true" :disabled="billableOptionLocked"
|
||||
>Billable</SelectItem
|
||||
>
|
||||
<SelectItem value="false">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -20,9 +20,12 @@ import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -263,7 +266,9 @@ const showBreakWarning = computed(
|
||||
<SelectItem value="do-not-update">
|
||||
Keep current billable status
|
||||
</SelectItem>
|
||||
<SelectItem value="billable">Billable</SelectItem>
|
||||
<SelectItem value="billable" :disabled="billableRatesLocked"
|
||||
>Billable</SelectItem
|
||||
>
|
||||
<SelectItem value="non-billable">Non Billable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
|
||||
defineProps<{
|
||||
// The Professional feature behind the badge, picks the variant of the upgrade modal
|
||||
feature: UpgradeFeature;
|
||||
}>();
|
||||
|
||||
const showUpgradeModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UpgradeModal v-model:show="showUpgradeModal">
|
||||
<slot>
|
||||
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
|
||||
</slot>
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
|
||||
<button
|
||||
data-testid="upgrade_badge"
|
||||
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
||||
@click.prevent="showUpgradeModal = true">
|
||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal file
17
resources/js/packages/ui/src/UpgradeLockedBadge.vue
Normal 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>
|
||||
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal file
22
resources/js/packages/ui/src/UpgradeLockedValue.vue
Normal 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>
|
||||
139
resources/js/packages/ui/src/UpgradeModal.vue
Normal file
139
resources/js/packages/ui/src/UpgradeModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
23
resources/js/packages/ui/src/UpgradeModal/MockupFrame.vue
Normal file
23
resources/js/packages/ui/src/UpgradeModal/MockupFrame.vue
Normal 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>
|
||||
54
resources/js/packages/ui/src/UpgradeModal/MockupGoals.vue
Normal file
54
resources/js/packages/ui/src/UpgradeModal/MockupGoals.vue
Normal 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>
|
||||
@@ -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>
|
||||
35
resources/js/packages/ui/src/UpgradeModal/MockupPdfPage.vue
Normal file
35
resources/js/packages/ui/src/UpgradeModal/MockupPdfPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
19
resources/js/packages/ui/src/UpgradeModal/featureIcons.ts
Normal file
19
resources/js/packages/ui/src/UpgradeModal/featureIcons.ts
Normal 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,
|
||||
};
|
||||
72
resources/js/packages/ui/src/UpgradeModal/useUpgradeCta.ts
Normal file
72
resources/js/packages/ui/src/UpgradeModal/useUpgradeCta.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
@@ -21,6 +21,10 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
|
||||
export type { ActivityPeriod } from './FullCalendar/activityTypes';
|
||||
export { cn } from './utils/cn';
|
||||
export { useCssVariable } from './utils/useCssVariable';
|
||||
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||
export type { BillableRatesLock } from './utils/useBillableRatesLock';
|
||||
export { upgradeCtaKey, useUpgradeCta } from './UpgradeModal/useUpgradeCta';
|
||||
export type { UpgradeCta, UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||
|
||||
import Badge from './Badge.vue';
|
||||
import Button from './Buttons/Button.vue';
|
||||
@@ -162,6 +166,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs/index';
|
||||
import TabBar from './TabBar/TabBar.vue';
|
||||
import TabBarItem from './TabBar/TabBarItem.vue';
|
||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||
import UpgradeBadge from './UpgradeBadge.vue';
|
||||
import UpgradeLockedBadge from './UpgradeLockedBadge.vue';
|
||||
import UpgradeLockedValue from './UpgradeLockedValue.vue';
|
||||
import UpgradeModal from './UpgradeModal.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
|
||||
export {
|
||||
@@ -305,4 +313,8 @@ export {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
UpgradeBadge,
|
||||
UpgradeLockedBadge,
|
||||
UpgradeLockedValue,
|
||||
UpgradeModal,
|
||||
};
|
||||
|
||||
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal file
22
resources/js/packages/ui/src/utils/useBillableRatesLock.ts
Normal 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: () => {},
|
||||
});
|
||||
}
|
||||
@@ -98,3 +98,13 @@ export function isAllowedToPerformPremiumAction() {
|
||||
(isBillingActivated() && isInTrial())
|
||||
);
|
||||
}
|
||||
|
||||
export function canUseBillableRates() {
|
||||
const page = usePage<{
|
||||
billing: {
|
||||
can_use_billable_rates?: boolean;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
return !isBillingActivated() || page.props.billing?.can_use_billable_rates !== false;
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ export function useCopyLastWeek(
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
|
||||
if (attempted === 0) return;
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useTimesheetCellMutations(
|
||||
);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
canCreateInvitations,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { fetchCanCreateMoreGoals } from '@/utils/useGoalsQuery';
|
||||
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
@@ -66,6 +68,7 @@ const showCreateClientModal = ref(false);
|
||||
const showCreateTaskModal = ref(false);
|
||||
const showCreateTagModal = ref(false);
|
||||
const showCreateGoalModal = ref(false);
|
||||
const showGoalUpgradeModal = ref(false);
|
||||
const showInviteMemberModal = ref(false);
|
||||
const showCreateTimeEntryModal = ref(false);
|
||||
|
||||
@@ -103,6 +106,7 @@ const ENTITY_ICONS: Record<string, typeof FolderIcon> = {
|
||||
};
|
||||
|
||||
export function useCommandPalette() {
|
||||
const queryClient = useQueryClient();
|
||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
|
||||
const { setActiveState, updateTimer } = currentTimeEntryStore;
|
||||
@@ -240,9 +244,13 @@ export function useCommandPalette() {
|
||||
showCreateTagModal.value = true;
|
||||
}
|
||||
|
||||
function openCreateGoalModal() {
|
||||
async function openCreateGoalModal() {
|
||||
closePaletteAfterAction();
|
||||
if (await fetchCanCreateMoreGoals(queryClient)) {
|
||||
showCreateGoalModal.value = true;
|
||||
} else {
|
||||
showGoalUpgradeModal.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function openInviteMemberModal() {
|
||||
@@ -525,6 +533,7 @@ export function useCommandPalette() {
|
||||
showCreateTaskModal,
|
||||
showCreateTagModal,
|
||||
showCreateGoalModal,
|
||||
showGoalUpgradeModal,
|
||||
showInviteMemberModal,
|
||||
showCreateTimeEntryModal,
|
||||
showProjectSelector,
|
||||
|
||||
@@ -261,6 +261,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
||||
stopLiveTimer();
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
} else {
|
||||
throw new Error(
|
||||
'Failed to fetch current time entry because organization ID is missing.'
|
||||
|
||||
@@ -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 { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import type { Goal } from '@/packages/api/src';
|
||||
import { computed } from 'vue';
|
||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
||||
|
||||
export type GoalArchivedFilter = 'true' | 'false' | 'all';
|
||||
|
||||
@@ -19,19 +20,38 @@ export async function fetchAllGoals(
|
||||
);
|
||||
}
|
||||
|
||||
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
|
||||
export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') {
|
||||
return {
|
||||
queryKey: ['goals', getCurrentOrganizationId(), archived],
|
||||
queryFn: async () => {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (!organizationId) throw new Error('No organization');
|
||||
const data = await fetchAllGoals(organizationId, archived);
|
||||
return { data };
|
||||
},
|
||||
enabled: () => !!getCurrentOrganizationId(),
|
||||
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
|
||||
refetchInterval: 1000 * 60,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,6 +59,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +107,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -127,6 +130,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,6 +156,7 @@ export function useTimeEntriesMutations() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ abstract class TestCase extends BaseTestCase
|
||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
||||
$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
|
||||
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
||||
$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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,4 +300,20 @@ class ChartEndpointTest extends EndpointTestAbstract
|
||||
// Assert
|
||||
$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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1155,4 +1155,56 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\IpLookup\IpLookupResponseDto;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
@@ -567,4 +568,85 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// Arrange
|
||||
@@ -1597,4 +1624,174 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,4 +501,40 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user