mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-14 16:53:17 +01:00
Compare commits
13 Commits
feature/bi
...
feature/go
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
127f1cbb3d | ||
|
|
adfd715836 | ||
|
|
812b3f2f54 | ||
|
|
4b25b2adfa | ||
|
|
9557296eb0 | ||
|
|
c69edc81be | ||
|
|
902270a189 | ||
|
|
f60c9ca457 | ||
|
|
1c41432389 | ||
|
|
3197c8c7a0 | ||
|
|
b8761dffce | ||
|
|
ba2b37f264 | ||
|
|
9e6013eaf1 |
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\BillableRateService;
|
||||||
use App\Service\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
@@ -154,12 +154,12 @@ class ChartController extends Controller
|
|||||||
*
|
*
|
||||||
* @response array{value: int, currency: string}
|
* @response array{value: int, currency: string}
|
||||||
*/
|
*/
|
||||||
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
|
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService, BillableRateService $billableRateService): JsonResponse
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
if (! $showBillableRate) {
|
if (! $showBillableRate) {
|
||||||
throw new AuthorizationException('You do not have permission to view billable rates.');
|
throw new AuthorizationException('You do not have permission to view billable rates.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,4 +49,9 @@ class Controller extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function canUseBillableRates(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return app(BillingContract::class)->canUseBillableRates($organization);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,9 @@ class MemberController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'members:update', $member);
|
$this->checkPermission($organization, 'members:update', $member);
|
||||||
|
|
||||||
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
|
$oldBillableRate = $member->billable_rate;
|
||||||
|
if ($request->has('billable_rate') && $this->canUseBillableRates($organization)) {
|
||||||
$member->billable_rate = $request->getBillableRate();
|
$member->billable_rate = $request->getBillableRate();
|
||||||
|
|
||||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
|
||||||
}
|
}
|
||||||
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
||||||
$newRole = $request->getRole();
|
$newRole = $request->getRole();
|
||||||
@@ -93,6 +92,10 @@ class MemberController extends Controller
|
|||||||
}
|
}
|
||||||
$member->save();
|
$member->save();
|
||||||
|
|
||||||
|
if ($oldBillableRate !== $member->billable_rate) {
|
||||||
|
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||||
|
}
|
||||||
|
|
||||||
return new MemberResource($member);
|
return new MemberResource($member);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Events\AfterCreateOrganization;
|
use App\Events\AfterCreateOrganization;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
use App\Http\Requests\V1\Organization\OrganizationDestroyRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
@@ -28,11 +27,11 @@ class OrganizationController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function show(Organization $organization): OrganizationResource
|
public function show(Organization $organization, BillableRateService $billableRateService): OrganizationResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:view');
|
$this->checkPermission($organization, 'organizations:view');
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
return new OrganizationResource($organization, $showBillableRate);
|
return new OrganizationResource($organization, $showBillableRate);
|
||||||
}
|
}
|
||||||
@@ -47,6 +46,7 @@ class OrganizationController extends Controller
|
|||||||
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:update');
|
$this->checkPermission($organization, 'organizations:update');
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
|
|
||||||
if ($request->getName() !== null) {
|
if ($request->getName() !== null) {
|
||||||
$organization->name = $request->getName();
|
$organization->name = $request->getName();
|
||||||
@@ -54,7 +54,8 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getCurrency() !== null) {
|
if ($request->getCurrency() !== null) {
|
||||||
$organization->currency = $request->getCurrency();
|
$organization->currency = $request->getCurrency();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
// Organizations that can not use billable rates can keep or remove the visibility for employees, but not enable it
|
||||||
|
if ($request->getEmployeesCanSeeBillableRates() !== null && ($canUseBillableRates || ! $request->getEmployeesCanSeeBillableRates())) {
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||||
@@ -81,14 +82,13 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getBreaksEnabled() !== null) {
|
if ($request->getBreaksEnabled() !== null) {
|
||||||
$organization->breaks_enabled = $request->getBreaksEnabled();
|
$organization->breaks_enabled = $request->getBreaksEnabled();
|
||||||
}
|
}
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$oldBillableRate = $organization->billable_rate;
|
||||||
if ($hasBillableRate) {
|
if ($request->has('billable_rate') && $canUseBillableRates) {
|
||||||
$oldBillableRate = $organization->billable_rate;
|
|
||||||
$organization->billable_rate = $request->getBillableRate();
|
$organization->billable_rate = $request->getBillableRate();
|
||||||
}
|
}
|
||||||
$organization->save();
|
$organization->save();
|
||||||
|
|
||||||
if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $organization->billable_rate) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||||
@@ -39,7 +38,7 @@ class ProjectController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId getProjects
|
* @operationId getProjects
|
||||||
*/
|
*/
|
||||||
public function index(Organization $organization, ProjectIndexRequest $request): ProjectCollection
|
public function index(Organization $organization, ProjectIndexRequest $request, BillableRateService $billableRateService): ProjectCollection
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:view');
|
$this->checkPermission($organization, 'projects:view');
|
||||||
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
$canViewAllProjects = $this->hasPermission($organization, 'projects:view:all');
|
||||||
@@ -63,7 +62,7 @@ class ProjectController extends Controller
|
|||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->paginate(config('app.pagination_per_page_default'));
|
->paginate(config('app.pagination_per_page_default'));
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
return new ProjectCollection($projects, $showBillableRate);
|
return new ProjectCollection($projects, $showBillableRate);
|
||||||
}
|
}
|
||||||
@@ -97,11 +96,12 @@ class ProjectController extends Controller
|
|||||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:create');
|
$this->checkPermission($organization, 'projects:create');
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
$project = new Project;
|
$project = new Project;
|
||||||
$project->name = $request->input('name');
|
$project->name = $request->input('name');
|
||||||
$project->color = $request->input('color');
|
$project->color = $request->input('color');
|
||||||
$project->is_billable = (bool) $request->input('is_billable');
|
$project->is_billable = $canUseBillableRates && $request->boolean('is_billable');
|
||||||
$project->billable_rate = $request->getBillableRate();
|
$project->billable_rate = $canUseBillableRates ? $request->getBillableRate() : null;
|
||||||
$project->client_id = $request->input('client_id');
|
$project->client_id = $request->input('client_id');
|
||||||
$project->is_public = $request->getIsPublic();
|
$project->is_public = $request->getIsPublic();
|
||||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||||
@@ -123,9 +123,13 @@ class ProjectController extends Controller
|
|||||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'projects:update', $project);
|
$this->checkPermission($organization, 'projects:update', $project);
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
$project->name = $request->input('name');
|
$project->name = $request->input('name');
|
||||||
$project->color = $request->input('color');
|
$project->color = $request->input('color');
|
||||||
$project->is_billable = (bool) $request->input('is_billable');
|
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||||
|
if ($canUseBillableRates || ! $request->boolean('is_billable')) {
|
||||||
|
$project->is_billable = $request->boolean('is_billable');
|
||||||
|
}
|
||||||
if ($request->has('is_archived')) {
|
if ($request->has('is_archived')) {
|
||||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||||
}
|
}
|
||||||
@@ -137,14 +141,16 @@ class ProjectController extends Controller
|
|||||||
}
|
}
|
||||||
$oldBillableRate = $project->billable_rate;
|
$oldBillableRate = $project->billable_rate;
|
||||||
$clientIdChanged = false;
|
$clientIdChanged = false;
|
||||||
$project->billable_rate = $request->getBillableRate();
|
if ($request->has('billable_rate') && $canUseBillableRates) {
|
||||||
|
$project->billable_rate = $request->getBillableRate();
|
||||||
|
}
|
||||||
if ($project->client_id !== $request->input('client_id')) {
|
if ($project->client_id !== $request->input('client_id')) {
|
||||||
$project->client_id = $request->input('client_id');
|
$project->client_id = $request->input('client_id');
|
||||||
$clientIdChanged = true;
|
$clientIdChanged = true;
|
||||||
}
|
}
|
||||||
$project->save();
|
$project->save();
|
||||||
|
|
||||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $project->billable_rate) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||||
}
|
}
|
||||||
if ($clientIdChanged) {
|
if ($clientIdChanged) {
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ class ProjectMemberController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$projectMember = new ProjectMember;
|
$projectMember = new ProjectMember;
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
$projectMember->billable_rate = $this->canUseBillableRates($organization) ? $request->getBillableRate() : null;
|
||||||
$projectMember->member()->associate($member);
|
$projectMember->member()->associate($member);
|
||||||
$projectMember->user()->associate($member->user);
|
$projectMember->user()->associate($member->user);
|
||||||
$projectMember->project()->associate($project);
|
$projectMember->project()->associate($project);
|
||||||
$projectMember->save();
|
$projectMember->save();
|
||||||
|
|
||||||
if ($request->getBillableRate() !== null) {
|
if ($projectMember->billable_rate !== null) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +97,12 @@ class ProjectMemberController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||||
$oldBillableRate = $projectMember->billable_rate;
|
$oldBillableRate = $projectMember->billable_rate;
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
if ($this->canUseBillableRates($organization)) {
|
||||||
|
$projectMember->billable_rate = $request->getBillableRate();
|
||||||
|
}
|
||||||
$projectMember->save();
|
$projectMember->save();
|
||||||
|
|
||||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
if ($oldBillableRate !== $projectMember->billable_rate) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Enums\TimeEntryType;
|
use App\Enums\TimeEntryType;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
@@ -30,6 +29,7 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
|
use App\Service\BillableRateService;
|
||||||
use App\Service\LocalizationService;
|
use App\Service\LocalizationService;
|
||||||
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
|
||||||
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
use App\Service\ReportExport\TimeEntriesDetailedExport;
|
||||||
@@ -229,7 +229,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* @operationId exportTimeEntries
|
* @operationId exportTimeEntries
|
||||||
*/
|
*/
|
||||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
$member = $this->member($organization);
|
||||||
/** @var Member|null $memberFilter */
|
/** @var Member|null $memberFilter */
|
||||||
@@ -247,7 +247,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): array
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
$member = $this->member($organization);
|
||||||
/** @var Member|null $memberFilter */
|
/** @var Member|null $memberFilter */
|
||||||
@@ -398,7 +398,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
@@ -436,7 +436,7 @@ class TimeEntryController extends Controller
|
|||||||
* @throws NoOutputFileInResponse
|
* @throws NoOutputFileInResponse
|
||||||
* @throws FeatureIsNotAvailableInFreePlanApiException
|
* @throws FeatureIsNotAvailableInFreePlanApiException
|
||||||
*/
|
*/
|
||||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService, BillableRateService $billableRateService): JsonResponse
|
||||||
{
|
{
|
||||||
$member = $this->member($organization);
|
$member = $this->member($organization);
|
||||||
/** @var Member|null $memberFilter */
|
/** @var Member|null $memberFilter */
|
||||||
@@ -453,7 +453,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$showBillableRate = $billableRateService->canSeeBillableRates($organization, $this->member($organization));
|
||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
@@ -647,6 +647,10 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
$timeEntry = new TimeEntry;
|
$timeEntry = new TimeEntry;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
|
// Organizations that can not use billable rates can not create billable time entries
|
||||||
|
if ($timeEntry->billable && ! $this->canUseBillableRates($organization)) {
|
||||||
|
$timeEntry->billable = false;
|
||||||
|
}
|
||||||
$timeEntry->client()->associate($client);
|
$timeEntry->client()->associate($client);
|
||||||
$timeEntry->user_id = $member->user_id;
|
$timeEntry->user_id = $member->user_id;
|
||||||
$timeEntry->description = $request->input('description') ?? '';
|
$timeEntry->description = $request->input('description') ?? '';
|
||||||
@@ -714,7 +718,12 @@ class TimeEntryController extends Controller
|
|||||||
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$wasBillable = $timeEntry->billable;
|
||||||
$timeEntry->fill($request->validated());
|
$timeEntry->fill($request->validated());
|
||||||
|
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||||
|
if ($timeEntry->billable && ! $wasBillable && ! $this->canUseBillableRates($organization)) {
|
||||||
|
$timeEntry->billable = false;
|
||||||
|
}
|
||||||
if ($newMember !== null) {
|
if ($newMember !== null) {
|
||||||
$timeEntry->member()->associate($newMember);
|
$timeEntry->member()->associate($newMember);
|
||||||
$timeEntry->user()->associate($newMember->user);
|
$timeEntry->user()->associate($newMember->user);
|
||||||
@@ -772,6 +781,7 @@ class TimeEntryController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$changes = $request->validated('changes');
|
$changes = $request->validated('changes');
|
||||||
|
$canUseBillableRates = $this->canUseBillableRates($organization);
|
||||||
|
|
||||||
if ($request->has('changes.description')) {
|
if ($request->has('changes.description')) {
|
||||||
$changes['description'] = $request->input('changes.description') ?? '';
|
$changes['description'] = $request->input('changes.description') ?? '';
|
||||||
@@ -837,8 +847,13 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
$wasBillable = $timeEntry->billable;
|
||||||
|
|
||||||
$timeEntry->fill($changes);
|
$timeEntry->fill($changes);
|
||||||
|
// Organizations that can not use billable rates can keep or remove the billable flag, but not set it
|
||||||
|
if (! $wasBillable && ! $canUseBillableRates) {
|
||||||
|
$timeEntry->billable = false;
|
||||||
|
}
|
||||||
if ($newMember !== null) {
|
if ($newMember !== null) {
|
||||||
$timeEntry->member()->associate($newMember);
|
$timeEntry->member()->associate($newMember);
|
||||||
$timeEntry->user_id = $newMember->user_id;
|
$timeEntry->user_id = $newMember->user_id;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||||
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
'goal_limit' => $billing->getGoalLimit($currentOrganization),
|
||||||
|
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
|
||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
|
|||||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||||
})->uuid(),
|
})->uuid(),
|
||||||
],
|
],
|
||||||
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
|
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency), the current rate is kept if the field is omitted
|
||||||
'billable_rate' => array_merge([
|
'billable_rate' => array_merge([
|
||||||
'nullable',
|
'nullable',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
|
|||||||
use App\Enums\TimeFormat;
|
use App\Enums\TimeFormat;
|
||||||
use App\Http\Resources\V1\BaseResource;
|
use App\Http\Resources\V1\BaseResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Service\BillingContract;
|
||||||
use App\Service\CurrencyService;
|
use App\Service\CurrencyService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ class OrganizationResource extends BaseResource
|
|||||||
'is_personal' => $this->resource->personal_team,
|
'is_personal' => $this->resource->personal_team,
|
||||||
/** Billable rate in cents per hour */
|
/** Billable rate in cents per hour */
|
||||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||||
|
/** Whether the organization's plan allows billable flags and rates */
|
||||||
|
'can_use_billable_rates' => app(BillingContract::class)->canUseBillableRates($this->resource),
|
||||||
/** Can members of the organization with role "employee" see the billable rates */
|
/** Can members of the organization with role "employee" see the billable rates */
|
||||||
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
|
||||||
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -13,6 +14,24 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
|
|
||||||
class BillableRateService
|
class BillableRateService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private BillingContract $billing,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function canUseBillableRates(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return $this->billing->canUseBillableRates($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSeeBillableRates(Organization $organization, Member $member): bool
|
||||||
|
{
|
||||||
|
if (! $this->canUseBillableRates($organization)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $member->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
}
|
||||||
|
|
||||||
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
|
public function updateTimeEntriesBillableRateForProjectMember(ProjectMember $projectMember): void
|
||||||
{
|
{
|
||||||
TimeEntry::query()
|
TimeEntry::query()
|
||||||
|
|||||||
@@ -63,4 +63,13 @@ class BillingContract
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the organization can set and see billable rates
|
||||||
|
* Without a billing system every organization can use billable rates
|
||||||
|
*/
|
||||||
|
public function canUseBillableRates(Organization $organization): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
|
||||||
}
|
}
|
||||||
if ($isBreak) {
|
if ($isBreak) {
|
||||||
// Breaks can not be billable or belong to a project/task (already skipped above)
|
// Breaks can not be billable or belong to a project/task (already skipped above)
|
||||||
|
|||||||
@@ -74,9 +74,16 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
|
|
||||||
protected BillableRateService $billableRateService;
|
protected BillableRateService $billableRateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizations that can not use billable rates import everything without billable flags and rates
|
||||||
|
*/
|
||||||
|
protected bool $canUseBillableRates;
|
||||||
|
|
||||||
public function init(Organization $organization): void
|
public function init(Organization $organization): void
|
||||||
{
|
{
|
||||||
$this->organization = $organization;
|
$this->organization = $organization;
|
||||||
|
$this->billableRateService = app(BillableRateService::class);
|
||||||
|
$this->canUseBillableRates = $this->billableRateService->canUseBillableRates($organization);
|
||||||
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
|
||||||
/** @var Builder<User> $builder */
|
/** @var Builder<User> $builder */
|
||||||
return $builder->belongsToOrganization($this->organization);
|
return $builder->belongsToOrganization($this->organization);
|
||||||
@@ -99,7 +106,11 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'string',
|
'string',
|
||||||
'in:placeholder',
|
'in:placeholder',
|
||||||
],
|
],
|
||||||
]);
|
], beforeSave: function (Member $member): void {
|
||||||
|
if (! $this->canUseBillableRates) {
|
||||||
|
$member->billable_rate = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
|
||||||
/** @var Builder<Project> $builder */
|
/** @var Builder<Project> $builder */
|
||||||
return $builder->where('organization_id', $this->organization->id);
|
return $builder->where('organization_id', $this->organization->id);
|
||||||
@@ -126,6 +137,10 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
if ($project->billable_rate === 0) {
|
if ($project->billable_rate === 0) {
|
||||||
$project->billable_rate = null;
|
$project->billable_rate = null;
|
||||||
}
|
}
|
||||||
|
if (! $this->canUseBillableRates) {
|
||||||
|
$project->is_billable = false;
|
||||||
|
$project->billable_rate = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
|
$this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder): Builder {
|
||||||
/** @var Builder<ProjectMember> $builder */
|
/** @var Builder<ProjectMember> $builder */
|
||||||
@@ -140,6 +155,9 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
if ($projectMember->billable_rate === 0) {
|
if ($projectMember->billable_rate === 0) {
|
||||||
$projectMember->billable_rate = null;
|
$projectMember->billable_rate = null;
|
||||||
}
|
}
|
||||||
|
if (! $this->canUseBillableRates) {
|
||||||
|
$projectMember->billable_rate = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder): Builder {
|
||||||
/** @var Builder<Tag> $builder */
|
/** @var Builder<Tag> $builder */
|
||||||
@@ -181,7 +199,6 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
$this->timeEntriesCreated = 0;
|
$this->timeEntriesCreated = 0;
|
||||||
$this->colorService = app(ColorService::class);
|
$this->colorService = app(ColorService::class);
|
||||||
$this->timezoneService = app(TimezoneService::class);
|
$this->timezoneService = app(TimezoneService::class);
|
||||||
$this->billableRateService = app(BillableRateService::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class GenericTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
if (! in_array($record['billable'], ['true', 'false'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['billable'] === 'true';
|
$timeEntry->billable = $this->canUseBillableRates && $record['billable'] === 'true';
|
||||||
$timeEntry->tags = $this->getTags($record['tags']);
|
$timeEntry->tags = $this->getTags($record['tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable?'] === 'Yes';
|
$timeEntry->billable = $this->canUseBillableRates && $record['Billable?'] === 'Yes';
|
||||||
$timeEntry->tags = [];
|
$timeEntry->tags = [];
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ class SolidtimeImporter extends DefaultImporter
|
|||||||
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
|
$timeEntry->billable = $this->canUseBillableRates && $timeEntryRow['billable'] === 'true';
|
||||||
// The type column does not exist in old exports
|
// The type column does not exist in old exports
|
||||||
if (($timeEntryRow['type'] ?? '') !== '') {
|
if (($timeEntryRow['type'] ?? '') !== '') {
|
||||||
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
|
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
|
|||||||
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
|
||||||
throw new ImportException('Invalid billable value');
|
throw new ImportException('Invalid billable value');
|
||||||
}
|
}
|
||||||
$timeEntry->billable = $record['Billable'] === 'Yes';
|
$timeEntry->billable = $this->canUseBillableRates && $record['Billable'] === 'Yes';
|
||||||
$timeEntry->tags = $this->getTags($record['Tags']);
|
$timeEntry->tags = $this->getTags($record['Tags']);
|
||||||
$timeEntry->is_imported = true;
|
$timeEntry->is_imported = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -342,6 +342,89 @@ test('test that a running time entry counts towards the goal progress', async ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close to midnight UTC an entry that started a few minutes ago could fall on the previous day, so
|
||||||
|
// the day of the goal is moved to a timezone (without daylight saving time) where it is late morning
|
||||||
|
function timezoneAwayFromMidnight() {
|
||||||
|
const utcHour = new Date().getUTCHours();
|
||||||
|
return utcHour >= 2 && utcHour < 22 ? 'UTC' : 'Asia/Tokyo';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigates via the sidebar, a full page load would start with an empty query cache and hide
|
||||||
|
// a missing goals invalidation
|
||||||
|
async function navigateViaSidebar(page: Page, name: 'Time' | 'Goals') {
|
||||||
|
await page.getByRole('link', { name, exact: true }).first().click();
|
||||||
|
await expect(page).toHaveURL(new RegExp('/' + name.toLowerCase() + '$'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test that goal progress updates after creating a time entry without a page reload', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const goal = await createGoalViaApi(ctx, {
|
||||||
|
name: 'Cache goal',
|
||||||
|
target_seconds: 3600,
|
||||||
|
period: 'day',
|
||||||
|
timezone: timezoneAwayFromMidnight(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToGoalsOverview(page);
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Time');
|
||||||
|
await page.getByRole('button', { name: 'Time entry actions' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
|
||||||
|
const durationInput = page.locator('[role="dialog"] input[name="Duration"]');
|
||||||
|
await durationInput.fill('15min');
|
||||||
|
await durationInput.press('Tab');
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) => response.url().includes('/time-entries') && response.status() === 201
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Create Time Entry' }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Goals');
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 15min');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test that goal progress updates after changing the project of the running time entry without a page reload', async ({
|
||||||
|
page,
|
||||||
|
ctx,
|
||||||
|
}) => {
|
||||||
|
const project = await createProjectViaApi(ctx, { name: 'Running Project', is_public: true });
|
||||||
|
const start = new Date(Date.now() - 30 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
await createRunningTimeEntryWithStartViaApi(ctx, 'Running work', start);
|
||||||
|
const goal = await createGoalViaApi(ctx, {
|
||||||
|
name: 'Running project goal',
|
||||||
|
target_seconds: 3600,
|
||||||
|
period: 'day',
|
||||||
|
timezone: timezoneAwayFromMidnight(),
|
||||||
|
filters: { project_ids: [project.id], time_entry_type: 'work' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await goToGoalsOverview(page);
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText('0h 00min');
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Time');
|
||||||
|
await page.getByRole('button', { name: 'No Project' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
async (response) =>
|
||||||
|
response.url().includes('/time-entries') &&
|
||||||
|
response.request().method() === 'PUT' &&
|
||||||
|
response.status() === 200 &&
|
||||||
|
(await response.json()).data.project_id === project.id
|
||||||
|
),
|
||||||
|
page.getByRole('option').filter({ hasText: project.name }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await navigateViaSidebar(page, 'Goals');
|
||||||
|
// The entry keeps running while the test runs
|
||||||
|
await expect(goalRow(page, goal.id).getByTestId('goal_tracked_time')).toHaveText(
|
||||||
|
/^0h 3[0-2]min$/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
test('test that editing a goal via the modal works', async ({ page, ctx }) => {
|
||||||
const goal = await createGoalViaApi(ctx, {
|
const goal = await createGoalViaApi(ctx, {
|
||||||
name: 'Old goal name',
|
name: 'Old goal name',
|
||||||
|
|||||||
@@ -884,7 +884,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
|
|||||||
await expect(table).toBeVisible();
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
// First click = desc (highest first), null rates last
|
// First click = desc (highest first), null rates last
|
||||||
const billableHeader = table.getByText('Billable Rate').first();
|
const billableHeader = table.getByRole('button', { name: /Billable Rate/ });
|
||||||
await billableHeader.click();
|
await billableHeader.click();
|
||||||
await expect(billableHeader.locator('svg')).toBeVisible();
|
await expect(billableHeader.locator('svg')).toBeVisible();
|
||||||
let names = await getTableRowNames(table);
|
let names = await getTableRowNames(table);
|
||||||
|
|||||||
14
e2e/utils/billing.ts
Normal file
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Billing": {
|
"Billing": {
|
||||||
"repository": "solidtime-io/extension-billing",
|
"repository": "solidtime-io/extension-billing",
|
||||||
"ref": "v0.0.9"
|
"ref": "v0.0.11"
|
||||||
},
|
},
|
||||||
"Services": {
|
"Services": {
|
||||||
"repository": "solidtime-io/extension-services",
|
"repository": "solidtime-io/extension-services",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"Invoicing": {
|
"Invoicing": {
|
||||||
"repository": "solidtime-io/extension-invoicing",
|
"repository": "solidtime-io/extension-invoicing",
|
||||||
"ref": "v0.0.10"
|
"ref": "v0.0.12"
|
||||||
},
|
},
|
||||||
"Auditing": {
|
"Auditing": {
|
||||||
"repository": "solidtime-io/extension-auditing",
|
"repository": "solidtime-io/extension-auditing",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { enabledExtensions } from './playwright/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -7,23 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||||||
*/
|
*/
|
||||||
// require('dotenv').config();
|
// require('dotenv').config();
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
|
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
|
||||||
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
|
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
|
||||||
* a checked out but disabled extension would fail its specs otherwise.
|
* a checked out but disabled extension would fail its specs otherwise.
|
||||||
*/
|
*
|
||||||
function enabledExtensions(): string[] {
|
|
||||||
const statusesFile = './modules_statuses.json';
|
|
||||||
if (!existsSync(statusesFile)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
|
||||||
return Object.entries(statuses)
|
|
||||||
.filter(([, enabled]) => enabled)
|
|
||||||
.map(([name]) => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
|
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
|
||||||
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
|
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||||
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
||||||
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions that are enabled in modules_statuses.json (php artisan module:enable <Name>)
|
||||||
|
*/
|
||||||
|
export function enabledExtensions(): string[] {
|
||||||
|
const statusesFile = './modules_statuses.json';
|
||||||
|
if (!existsSync(statusesFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
||||||
|
return Object.entries(statuses)
|
||||||
|
.filter(([, enabled]) => enabled)
|
||||||
|
.map(([name]) => name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExtensionEnabled(name: string): boolean {
|
||||||
|
return enabledExtensions().includes(name);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,137 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
import { XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import {
|
import { isBillingActivated, isBlocked } from '@/utils/billing';
|
||||||
daysLeftInTrial,
|
|
||||||
isBillingActivated,
|
|
||||||
isBlocked,
|
|
||||||
isFreePlan,
|
|
||||||
isInTrial,
|
|
||||||
} from '@/utils/billing';
|
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import { canManageBilling } from '@/utils/permissions';
|
import { canManageBilling } from '@/utils/permissions';
|
||||||
|
|
||||||
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
|
|
||||||
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
|
|
||||||
const hideBlockedBanner = useSessionStorage(
|
const hideBlockedBanner = useSessionStorage(
|
||||||
'showBlockedBanner-' + getCurrentOrganizationId(),
|
'showBlockedBanner-' + getCurrentOrganizationId(),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
||||||
const hideFreeUpgradeBanner = useSessionStorage(
|
|
||||||
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const showFreeUpgradeBanner = computed(
|
|
||||||
() =>
|
|
||||||
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
|
|
||||||
);
|
|
||||||
const hideBlackFridayBanner = useSessionStorage(
|
|
||||||
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
const showBlackFridayBanner = computed(() => {
|
|
||||||
if (hideBlackFridayBanner.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const today = new Date();
|
|
||||||
const blackFriday = new Date(2024, 10, 30);
|
|
||||||
return today < blackFriday;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
|
||||||
v-if="showBlackFridayBanner"
|
|
||||||
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
|
|
||||||
<MainContainer class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="#FF37AD"
|
|
||||||
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
|
|
||||||
<path
|
|
||||||
fill="#FCC954"
|
|
||||||
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
|
|
||||||
<path
|
|
||||||
fill="#2890E9"
|
|
||||||
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
|
|
||||||
<path
|
|
||||||
fill="#F0A420"
|
|
||||||
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
|
|
||||||
<path
|
|
||||||
fill="#FCC954"
|
|
||||||
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
|
|
||||||
<path
|
|
||||||
fill="#F0A420"
|
|
||||||
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
|
|
||||||
<path
|
|
||||||
fill="#2E6AC9"
|
|
||||||
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
|
|
||||||
<path
|
|
||||||
fill="#2890E9"
|
|
||||||
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
|
|
||||||
<path
|
|
||||||
fill="#FF37AD"
|
|
||||||
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
|
|
||||||
<path
|
|
||||||
fill="#2890E9"
|
|
||||||
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
|
|
||||||
</svg>
|
|
||||||
<div class="flex-1 space-x-1">
|
|
||||||
<span class="font-medium">
|
|
||||||
<strong>BLACK FRIDAY SALE!</strong> Use the code
|
|
||||||
<strong>BLACKFRIDAY</strong> at checkout and get
|
|
||||||
<strong>30% off</strong> the solidtime yearly plan.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link v-if="canManageBilling()" href="/billing">
|
|
||||||
<div
|
|
||||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
|
||||||
<span>Upgrade now</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button class="p-1" @click="hideBlackFridayBanner = true">
|
|
||||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showTrialBanner"
|
|
||||||
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
|
||||||
<MainContainer class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
|
|
||||||
<div class="flex-1 space-x-1">
|
|
||||||
<span class="font-medium">
|
|
||||||
Your trial expires in {{ daysLeftInTrial() }} days.
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">
|
|
||||||
To continue using all features & support the development of solidtime,
|
|
||||||
please upgrade your plan.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link v-if="canManageBilling()" href="/billing">
|
|
||||||
<div
|
|
||||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
|
||||||
<span>Upgrade now</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button class="p-1" @click="hideTrialBanner = true">
|
|
||||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="showBlockedBanner"
|
v-if="showBlockedBanner"
|
||||||
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||||
@@ -159,33 +43,6 @@ const showBlackFridayBanner = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="showFreeUpgradeBanner"
|
|
||||||
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
|
||||||
<MainContainer class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-1.5">
|
|
||||||
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
|
|
||||||
<div class="flex-1 space-x-1">
|
|
||||||
<span class="font-medium"> You are currently using the Free Plan. </span>
|
|
||||||
<span class="hidden md:inline">
|
|
||||||
To unlock all premium features & support the development of solidtime,
|
|
||||||
please upgrade your plan.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
|
||||||
<div
|
|
||||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
|
||||||
<span>Upgrade now</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
|
||||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MainContainer>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
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 TaskCreateModal from '@/Components/Common/Task/TaskCreateModal.vue';
|
||||||
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
import TagCreateModal from '@/packages/ui/src/Tag/TagCreateModal.vue';
|
||||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||||
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
import MemberInviteModal from '@/Components/Common/Member/MemberInviteModal.vue';
|
||||||
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
import TimeEntryCreateModal from '@/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ const {
|
|||||||
showCreateTaskModal,
|
showCreateTaskModal,
|
||||||
showCreateTagModal,
|
showCreateTagModal,
|
||||||
showCreateGoalModal,
|
showCreateGoalModal,
|
||||||
|
showGoalUpgradeModal,
|
||||||
showInviteMemberModal,
|
showInviteMemberModal,
|
||||||
showCreateTimeEntryModal,
|
showCreateTimeEntryModal,
|
||||||
showProjectSelector,
|
showProjectSelector,
|
||||||
@@ -185,6 +187,7 @@ const firstProjectId = computed(() => projects.value[0]?.id ?? '');
|
|||||||
|
|
||||||
<!-- Goal Create Modal -->
|
<!-- Goal Create Modal -->
|
||||||
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
<GoalCreateModal v-model:show="showCreateGoalModal" />
|
||||||
|
<UpgradeModal v-model:show="showGoalUpgradeModal" feature="goals" />
|
||||||
|
|
||||||
<!-- Member Invite Modal -->
|
<!-- Member Invite Modal -->
|
||||||
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
<MemberInviteModal v-model:show="showInviteMemberModal" :available-roles="availableRoles" />
|
||||||
|
|||||||
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">
|
<script setup lang="ts">
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import { Target } from '@lucide/vue';
|
import { Target } from '@lucide/vue';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import type { Goal } from '@/packages/api/src';
|
import type { Goal } from '@/packages/api/src';
|
||||||
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
import GoalTableRow from '@/Components/Common/Goal/GoalTableRow.vue';
|
||||||
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
import GoalTableHeading from '@/Components/Common/Goal/GoalTableHeading.vue';
|
||||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||||
import { canCreateGoals } from '@/utils/permissions';
|
import { canCreateGoals } from '@/utils/permissions';
|
||||||
import { LoadingSpinner } from '@/packages/ui/src';
|
import { LoadingSpinner } from '@/packages/ui/src';
|
||||||
import { isGoalsExtensionActivated } from '@/utils/billing';
|
import { isGoalsExtensionActivated } from '@/utils/billing';
|
||||||
@@ -18,7 +15,6 @@ const props = defineProps<{
|
|||||||
showingArchived: boolean;
|
showingArchived: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showCreateGoalModal = ref(false);
|
|
||||||
const showTargetColumn = isGoalsExtensionActivated();
|
const showTargetColumn = isGoalsExtensionActivated();
|
||||||
// Every column gets a share of the free space so the columns are spread evenly over the table
|
// Every column gets a share of the free space so the columns are spread evenly over the table
|
||||||
const gridTemplateColumns = [
|
const gridTemplateColumns = [
|
||||||
@@ -33,7 +29,6 @@ const gridTemplateColumns = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
|
||||||
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
<div class="flow-root max-w-[100vw] overflow-x-auto">
|
||||||
<div class="inline-block min-w-full align-middle">
|
<div class="inline-block min-w-full align-middle">
|
||||||
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
<div data-testid="goal_table" class="grid min-w-full" :style="{ gridTemplateColumns }">
|
||||||
@@ -51,14 +46,11 @@ const gridTemplateColumns = [
|
|||||||
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
<p v-if="canCreateGoals() && !props.showingArchived" class="pb-5">
|
||||||
Set yourself a target and track your progress every day, week or month.
|
Set yourself a target and track your progress every day, week or month.
|
||||||
</p>
|
</p>
|
||||||
<SecondaryButton
|
<GoalCreateButton
|
||||||
v-if="
|
v-if="canCreateGoals() && !props.showingArchived"
|
||||||
canCreateGoals() && !props.showingArchived && props.canCreateMoreGoals
|
:can-create-more-goals="props.canCreateMoreGoals"
|
||||||
"
|
|
||||||
:icon="PlusIcon"
|
|
||||||
@click="showCreateGoalModal = true"
|
|
||||||
>Create your first goal
|
>Create your first goal
|
||||||
</SecondaryButton>
|
</GoalCreateButton>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="goal in props.goals" :key="goal.id">
|
<template v-for="goal in props.goals" :key="goal.id">
|
||||||
<GoalTableRow
|
<GoalTableRow
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const deleteMutation = useMutation({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
close();
|
close();
|
||||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
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 BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
|
|
||||||
const { updateMember } = useMembersStore();
|
const { updateMember } = useMembersStore();
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
@@ -71,6 +72,8 @@ function saveWithChecks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -159,7 +162,12 @@ const roleDescription = computed(() => {
|
|||||||
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
||||||
>Billable Rate</FieldLabel
|
>Billable Rate</FieldLabel
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<BillableRateInput
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
v-model="displayedRate"
|
||||||
|
:currency="getOrganizationCurrencyString()"
|
||||||
|
name="memberBillableRate" />
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<Select v-model="billableRateSelect">
|
<Select v-model="billableRateSelect">
|
||||||
<SelectTrigger id="billableRateType">
|
<SelectTrigger id="billableRateType">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async function submit() {
|
|||||||
'There was an error merging the members.',
|
'There was an error merging the members.',
|
||||||
() => {
|
() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['members'] });
|
queryClient.invalidateQueries({ queryKey: ['members'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
show.value = false;
|
show.value = false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import type { Member, Organization } from '@/packages/api/src';
|
import type { Member, Organization } from '@/packages/api/src';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
|
||||||
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
|
|||||||
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
|
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
|
||||||
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
|
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
|
||||||
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
|
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||||
import { formatCents } from '../../../packages/ui/src/utils/money';
|
import { formatCents } from '../../../packages/ui/src/utils/money';
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
|
|||||||
const userHasValidMailAddress = computed(() => {
|
const userHasValidMailAddress = computed(() => {
|
||||||
return !props.member.email.endsWith('@solidtime-import.test');
|
return !props.member.email.endsWith('@solidtime-import.test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -89,8 +93,9 @@ const userHasValidMailAddress = computed(() => {
|
|||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
{{ capitalizeFirstLetter(member.role) }}
|
{{ capitalizeFirstLetter(member.role) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<span v-if="member.billable_rate">
|
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||||
|
<span v-else-if="member.billable_rate">
|
||||||
{{
|
{{
|
||||||
formatCents(
|
formatCents(
|
||||||
member.billable_rate,
|
member.billable_rate,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
|
||||||
import type { Project } from '@/packages/api/src';
|
import type { Project } from '@/packages/api/src';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
@@ -15,10 +16,11 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
|
|||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
|
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
|
||||||
@@ -78,6 +80,8 @@ const billableRateInfo = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -100,13 +104,15 @@ const showEditProjectModal = ref(false);
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
<div
|
||||||
|
class="whitespace-nowrap min-w-0 px-3 py-4 flex items-center text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
{{ client?.name }}
|
{{ client?.name }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-text-tertiary">No client</div>
|
<div v-else class="text-text-tertiary">No client</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
<div
|
||||||
|
class="whitespace-nowrap px-3 py-4 flex items-center text-sm text-text-primary">
|
||||||
<div v-if="project.spent_time">
|
<div v-if="project.spent_time">
|
||||||
{{
|
{{
|
||||||
formatHumanReadableDuration(
|
formatHumanReadableDuration(
|
||||||
@@ -119,7 +125,9 @@ const showEditProjectModal = ref(false);
|
|||||||
<div v-else class="text-text-tertiary">--</div>
|
<div v-else class="text-text-tertiary">--</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
<UpgradeBadge
|
||||||
|
v-if="!isAllowedToPerformPremiumAction()"
|
||||||
|
feature="estimates"></UpgradeBadge>
|
||||||
<EstimatedTimeProgress
|
<EstimatedTimeProgress
|
||||||
v-else-if="project.estimated_time"
|
v-else-if="project.estimated_time"
|
||||||
:estimated="project.estimated_time"
|
:estimated="project.estimated_time"
|
||||||
@@ -128,8 +136,9 @@ const showEditProjectModal = ref(false);
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||||
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||||
|
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||||
<span v-else class="text-text-tertiary">--</span>
|
<span v-else class="text-text-tertiary">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import type { ProjectMember } from '@/packages/api/src';
|
import type { ProjectMember } from '@/packages/api/src';
|
||||||
import { computed, ref, inject, type ComputedRef } from 'vue';
|
import { computed, ref, inject, type ComputedRef } from 'vue';
|
||||||
import TableRow from '@/Components/TableRow.vue';
|
import TableRow from '@/Components/TableRow.vue';
|
||||||
@@ -10,6 +11,7 @@ import { capitalizeFirstLetter } from '@/utils/format';
|
|||||||
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
|
import ProjectMemberEditModal from '@/Components/Common/ProjectMember/ProjectMemberEditModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectMember: ProjectMember;
|
projectMember: ProjectMember;
|
||||||
@@ -33,6 +35,8 @@ const member = computed(() => {
|
|||||||
return members.value.find((member) => member.id === props.projectMember.member_id);
|
return members.value.find((member) => member.id === props.projectMember.member_id);
|
||||||
});
|
});
|
||||||
const showEditModal = ref(false);
|
const showEditModal = ref(false);
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,18 +51,21 @@ const showEditModal = ref(false);
|
|||||||
{{ member?.name }}
|
{{ member?.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||||
{{
|
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
||||||
projectMember.billable_rate
|
<span v-else>
|
||||||
? formatCents(
|
{{
|
||||||
projectMember.billable_rate,
|
projectMember.billable_rate
|
||||||
getOrganizationCurrencyString(),
|
? formatCents(
|
||||||
organization?.currency_format,
|
projectMember.billable_rate,
|
||||||
organization?.currency_symbol,
|
getOrganizationCurrencyString(),
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
|
||||||
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
{{ capitalizeFirstLetter(member?.role ?? '') }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'
|
|||||||
import { h, ref } from 'vue';
|
import { h, ref } from 'vue';
|
||||||
import type { CreateReportBodyProperties } from '@/packages/api/src';
|
import type { CreateReportBodyProperties } from '@/packages/api/src';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
import { canCreateReports } from '@/utils/permissions';
|
import { canCreateReports } from '@/utils/permissions';
|
||||||
defineProps<{
|
defineProps<{
|
||||||
reportProperties: CreateReportBodyProperties;
|
reportProperties: CreateReportBodyProperties;
|
||||||
@@ -30,9 +30,7 @@ function onSaveReportClick() {
|
|||||||
<ReportCreateModal
|
<ReportCreateModal
|
||||||
v-model:show="showCreateReportModal"
|
v-model:show="showCreateReportModal"
|
||||||
:properties="reportProperties"></ReportCreateModal>
|
:properties="reportProperties"></ReportCreateModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" feature="shareable-reports" />
|
||||||
<strong>Sharable Reports</strong> is only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
||||||
>Save Report</SecondaryButton
|
>Save Report</SecondaryButton
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { ExportFormat } from '@/types/reporting';
|
import type { ExportFormat } from '@/types/reporting';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
download: (format: ExportFormat) => Promise<void>;
|
download: (format: ExportFormat) => Promise<void>;
|
||||||
@@ -48,9 +48,7 @@ function triggerDownload(format: ExportFormat) {
|
|||||||
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
|
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||||
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { CheckCircleIcon, LockClosedIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import { FolderIcon } from '@heroicons/vue/16/solid';
|
import { FolderIcon } from '@heroicons/vue/16/solid';
|
||||||
import { Check, Coffee } from '@lucide/vue';
|
import { Check, Coffee } from '@lucide/vue';
|
||||||
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
|
||||||
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
|
||||||
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
import ClientMultiselectDropdown from '@/Components/Common/Client/ClientMultiselectDropdown.vue';
|
||||||
@@ -12,6 +13,7 @@ import MemberMultiselectDropdown from '@/Components/Common/Member/MemberMultisel
|
|||||||
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
import ReportingFilterBadge from '@/Components/Common/Reporting/ReportingFilterBadge.vue';
|
||||||
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
import ProjectMultiselectDropdown from '@/Components/Common/Project/ProjectMultiselectDropdown.vue';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
|
||||||
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
@@ -35,6 +37,8 @@ const roundingMinutes = defineModel<number>('roundingMinutes', { required: true
|
|||||||
const startDate = defineModel<string>('startDate', { required: true });
|
const startDate = defineModel<string>('startDate', { required: true });
|
||||||
const endDate = defineModel<string>('endDate', { required: true });
|
const endDate = defineModel<string>('endDate', { required: true });
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
submit: [];
|
submit: [];
|
||||||
}>();
|
}>();
|
||||||
@@ -141,7 +145,17 @@ async function createTag(name: string) {
|
|||||||
</template>
|
</template>
|
||||||
</TagDropdown>
|
</TagDropdown>
|
||||||
|
|
||||||
<Select v-model="billable" @update:model-value="emit('submit')">
|
<Button
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="billable_filter_locked"
|
||||||
|
@click="requestUpgrade">
|
||||||
|
<BillableIcon class="h-4 text-text-quaternary" />
|
||||||
|
<span class="text-text-secondary">Billable</span>
|
||||||
|
<LockClosedIcon class="!w-3 text-text-tertiary" />
|
||||||
|
</Button>
|
||||||
|
<Select v-else v-model="billable" @update:model-value="emit('submit')">
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
@@ -30,7 +32,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/packages/ui/src';
|
} from '@/packages/ui/src';
|
||||||
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
import type { UpgradeFeature } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||||
import { canCreateReports } from '@/utils/permissions';
|
import { canCreateReports } from '@/utils/permissions';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
||||||
@@ -214,10 +217,13 @@ const showExportModal = ref(false);
|
|||||||
const exportUrl = ref<string | null>(null);
|
const exportUrl = ref<string | null>(null);
|
||||||
const showCreateReportModal = ref(false);
|
const showCreateReportModal = ref(false);
|
||||||
const showPremiumModal = ref(false);
|
const showPremiumModal = ref(false);
|
||||||
|
// Note: The premium modal is shared by the PDF export and saving a report
|
||||||
|
const premiumFeature = ref<UpgradeFeature>('pdf-reports');
|
||||||
const exportLoading = ref(false);
|
const exportLoading = ref(false);
|
||||||
|
|
||||||
function triggerExport(format: ExportFormat) {
|
function triggerExport(format: ExportFormat) {
|
||||||
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
||||||
|
premiumFeature.value = 'pdf-reports';
|
||||||
showPremiumModal.value = true;
|
showPremiumModal.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,6 +237,7 @@ function onSaveReportClick() {
|
|||||||
if (isAllowedToPerformPremiumAction()) {
|
if (isAllowedToPerformPremiumAction()) {
|
||||||
showCreateReportModal.value = true;
|
showCreateReportModal.value = true;
|
||||||
} else {
|
} else {
|
||||||
|
premiumFeature.value = 'shareable-reports';
|
||||||
showPremiumModal.value = true;
|
showPremiumModal.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +297,8 @@ const tableData = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -299,9 +308,7 @@ const tableData = computed(() => {
|
|||||||
<ReportCreateModal
|
<ReportCreateModal
|
||||||
v-model:show="showCreateReportModal"
|
v-model:show="showCreateReportModal"
|
||||||
:properties="reportProperties"></ReportCreateModal>
|
:properties="reportProperties"></ReportCreateModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" :feature="premiumFeature" />
|
||||||
This feature is only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
@@ -447,17 +454,20 @@ const tableData = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||||
aggregatedTableTimeEntries.cost
|
<template v-else>
|
||||||
? formatCents(
|
{{
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost
|
||||||
getOrganizationCurrencyString(),
|
? formatCents(
|
||||||
organization?.currency_format,
|
aggregatedTableTimeEntries.cost,
|
||||||
organization?.currency_symbol,
|
getOrganizationCurrencyString(),
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
import { formatReportingDuration } from '@/packages/ui/src/utils/time';
|
||||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { ref, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
@@ -27,6 +29,8 @@ const props = defineProps<{
|
|||||||
const expanded = ref(false);
|
const expanded = ref(false);
|
||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -53,17 +57,20 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showCost" class="justify-end pr-6 flex items-center">
|
<div v-if="showCost" class="justify-end pr-6 flex items-center">
|
||||||
{{
|
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||||
entry.cost
|
<template v-else>
|
||||||
? formatCents(
|
{{
|
||||||
entry.cost,
|
entry.cost
|
||||||
props.currency,
|
? formatCents(
|
||||||
organization?.currency_format,
|
entry.cost,
|
||||||
organization?.currency_symbol,
|
props.currency,
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ defineProps<{
|
|||||||
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
|
||||||
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
<dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
|
||||||
<dd class="text-xl text-text-primary pt-1 font-medium">
|
<dd class="text-xl text-text-primary pt-1 font-medium">
|
||||||
{{ value ?? '--' }}
|
<slot>{{ value ?? '--' }}</slot>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
|
|||||||
import { ref, inject, type ComputedRef } from 'vue';
|
import { ref, inject, type ComputedRef } from 'vue';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||||
import type { Organization } from '@/packages/api/src';
|
import type { Organization } from '@/packages/api/src';
|
||||||
|
|
||||||
@@ -55,7 +55,9 @@ const showTaskEditModal = ref(false);
|
|||||||
<span v-else> -- </span>
|
<span v-else> -- </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
<UpgradeBadge
|
||||||
|
v-if="!isAllowedToPerformPremiumAction()"
|
||||||
|
feature="estimates"></UpgradeBadge>
|
||||||
<EstimatedTimeProgress
|
<EstimatedTimeProgress
|
||||||
v-else-if="task.estimated_time"
|
v-else-if="task.estimated_time"
|
||||||
:estimated="task.estimated_time"
|
:estimated="task.estimated_time"
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
import { CanvasRenderer } from 'echarts/renderers';
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
import { BarChart } from 'echarts/charts';
|
import { BarChart } from 'echarts/charts';
|
||||||
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
|
|||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
const { data: totalWeeklyBillableAmount } = useQuery({
|
const { data: totalWeeklyBillableAmount } = useQuery({
|
||||||
queryKey: ['totalWeeklyBillableAmount', organizationId],
|
queryKey: ['totalWeeklyBillableAmount', organizationId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value),
|
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
|
||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +274,11 @@ const option = computed(() => {
|
|||||||
)
|
)
|
||||||
: '--'
|
: '--'
|
||||||
" />
|
" />
|
||||||
|
<StatCard v-if="billableRatesLocked" title="Billable Amount">
|
||||||
|
<UpgradeLockedValue />
|
||||||
|
</StatCard>
|
||||||
<StatCard
|
<StatCard
|
||||||
|
v-else
|
||||||
title="Billable Amount"
|
title="Billable Amount"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableAmount
|
totalWeeklyBillableAmount
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedValue from '@/packages/ui/src/UpgradeLockedValue.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
|
||||||
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
|
||||||
import {
|
import {
|
||||||
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
|
|||||||
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -190,17 +194,20 @@ const showBillableRate = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-if="showBillableRate"
|
v-if="showBillableRate"
|
||||||
class="justify-end pr-6 flex items-center font-medium">
|
class="justify-end pr-6 flex items-center font-medium">
|
||||||
{{
|
<UpgradeLockedValue v-if="billableRatesLocked" />
|
||||||
aggregatedTableTimeEntries.cost
|
<template v-else>
|
||||||
? formatCents(
|
{{
|
||||||
aggregatedTableTimeEntries.cost,
|
aggregatedTableTimeEntries.cost
|
||||||
getOrganizationCurrencyString(),
|
? formatCents(
|
||||||
organization?.currency_format,
|
aggregatedTableTimeEntries.cost,
|
||||||
organization?.currency_symbol,
|
getOrganizationCurrencyString(),
|
||||||
organization?.number_format
|
organization?.currency_format,
|
||||||
)
|
organization?.currency_symbol,
|
||||||
: '--'
|
organization?.number_format
|
||||||
}}
|
)
|
||||||
|
: '--'
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, usePage } from '@inertiajs/vue3';
|
import { Head, router, usePage } from '@inertiajs/vue3';
|
||||||
import Banner from '@/Components/Banner.vue';
|
import Banner from '@/Components/Banner.vue';
|
||||||
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||||
@@ -23,7 +23,7 @@ import { PanelLeft, Target } from '@lucide/vue';
|
|||||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import { nextTick, onMounted, provide, ref } from 'vue';
|
import { computed, nextTick, onMounted, provide, ref } from 'vue';
|
||||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||||
import { initializeStores } from '@/utils/init';
|
import { initializeStores } from '@/utils/init';
|
||||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||||
@@ -38,12 +38,16 @@ import {
|
|||||||
canViewReport,
|
canViewReport,
|
||||||
canViewTags,
|
canViewTags,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||||
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
|
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
|
import { upgradeCtaKey } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||||
import type { User } from '@/types/models';
|
import type { User } from '@/types/models';
|
||||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||||
|
import SidebarPlanStatus from '@/Components/Billing/SidebarPlanStatus.vue';
|
||||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||||
import { useTheme } from '@/utils/theme';
|
import { useTheme } from '@/utils/theme';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
@@ -89,6 +93,19 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
|||||||
|
|
||||||
provide('organization', organization);
|
provide('organization', organization);
|
||||||
|
|
||||||
|
const showBillableRatesUpgradeModal = ref(false);
|
||||||
|
provide(billableRatesLockKey, {
|
||||||
|
locked: computed(() => !canUseBillableRates()),
|
||||||
|
requestUpgrade: () => {
|
||||||
|
showBillableRatesUpgradeModal.value = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
provide(upgradeCtaKey, {
|
||||||
|
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
|
||||||
|
goToBilling: () => router.visit('/billing'),
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
useTheme();
|
useTheme();
|
||||||
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
||||||
@@ -306,6 +323,7 @@ const page = usePage<{
|
|||||||
</div>
|
</div>
|
||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||||
|
<SidebarPlanStatus v-if="isBillingActivated() && canManageBilling()" />
|
||||||
<ul
|
<ul
|
||||||
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
||||||
<UserSettingsIcon></UserSettingsIcon>
|
<UserSettingsIcon></UserSettingsIcon>
|
||||||
@@ -384,4 +402,5 @@ const page = usePage<{
|
|||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||||
<CommandPaletteProvider></CommandPaletteProvider>
|
<CommandPaletteProvider></CommandPaletteProvider>
|
||||||
|
<UpgradeModal v-model:show="showBillableRatesUpgradeModal" feature="billable-rates" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { Target } from '@lucide/vue';
|
import { Target } from '@lucide/vue';
|
||||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
|
||||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
||||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
import GoalCreateButton from '@/Components/Common/Goal/GoalCreateButton.vue';
|
||||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
|
||||||
import { canCreateGoals } from '@/utils/permissions';
|
import { canCreateGoals } from '@/utils/permissions';
|
||||||
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
import { canCreateMoreGoals } from '@/utils/billing';
|
||||||
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
||||||
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
||||||
|
|
||||||
const showCreateGoalModal = ref(false);
|
|
||||||
const activeTab = ref('active');
|
const activeTab = ref('active');
|
||||||
const showArchived = computed(() => activeTab.value === 'archived');
|
const showArchived = computed(() => activeTab.value === 'archived');
|
||||||
|
|
||||||
@@ -25,7 +21,6 @@ const visibleGoals = computed(() =>
|
|||||||
);
|
);
|
||||||
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
||||||
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
||||||
const limit = goalLimit();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -42,19 +37,9 @@ const limit = goalLimit();
|
|||||||
</TabBarItem>
|
</TabBarItem>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
<GoalCreateButton
|
||||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
|
v-if="canCreateGoals()"
|
||||||
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
|
:can-create-more-goals="canCreateMore"></GoalCreateButton>
|
||||||
available in solidtime Professional.
|
|
||||||
</UpgradeBadge>
|
|
||||||
<SecondaryButton
|
|
||||||
v-if="canCreateGoals() && canCreateMore"
|
|
||||||
:icon="PlusIcon"
|
|
||||||
@click="showCreateGoalModal = true"
|
|
||||||
>Create Goal
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
<GoalCreateModal v-model:show="showCreateGoalModal"></GoalCreateModal>
|
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
<GoalTable
|
<GoalTable
|
||||||
:goals="visibleGoals"
|
:goals="visibleGoals"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
||||||
|
import { canUseBillableRates } from '@/utils/billing';
|
||||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
|
||||||
@@ -80,6 +82,9 @@ const shownTasks = computed(() => {
|
|||||||
return task.project_id === projectId && task.is_done;
|
return task.project_id === projectId && task.is_done;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: The page sits above the app layout that provides the lock, so it can not inject it
|
||||||
|
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -117,11 +122,17 @@ const shownTasks = computed(() => {
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="px-4 space-x-1">
|
<div class="px-4 space-x-1">
|
||||||
<Badge v-if="project?.billable_rate">
|
<UpgradeLockedBadge v-if="billableRatesLocked"
|
||||||
|
>Billable Rates</UpgradeLockedBadge
|
||||||
|
>
|
||||||
|
<Badge v-else-if="project?.billable_rate">
|
||||||
{{ billableRateFormatted }}
|
{{ billableRateFormatted }}
|
||||||
/ h
|
/ h
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="project?.is_billable && !project?.billable_rate">
|
<Badge
|
||||||
|
v-if="
|
||||||
|
project?.is_billable && !project?.billable_rate && !billableRatesLocked
|
||||||
|
">
|
||||||
Default Rate
|
Default Rate
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useMembersQuery } from '@/utils/useMembersQuery';
|
|||||||
import { useQueryClient } from '@tanstack/vue-query';
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||||
import type { ExportFormat } from '@/types/reporting';
|
import type { ExportFormat } from '@/types/reporting';
|
||||||
import { useNotificationsStore } from '@/utils/notification';
|
import { useNotificationsStore } from '@/utils/notification';
|
||||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||||
@@ -253,9 +253,7 @@ async function downloadExport(format: ExportFormat) {
|
|||||||
<ReportingExportModal
|
<ReportingExportModal
|
||||||
v-model:show="showExportModal"
|
v-model:show="showExportModal"
|
||||||
:export-url="exportUrl"></ReportingExportModal>
|
:export-url="exportUrl"></ReportingExportModal>
|
||||||
<UpgradeModal v-model:show="showPremiumModal">
|
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
<MainContainer
|
<MainContainer
|
||||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { useNotificationsStore } from '@/utils/notification';
|
|||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
import { Field, FieldLabel } from '@/packages/ui/src/field';
|
||||||
import { DocumentIcon } from '@heroicons/vue/24/solid';
|
import { DocumentIcon } from '@heroicons/vue/24/solid';
|
||||||
import { ArrowDownOnSquareIcon, InformationCircleIcon } from '@heroicons/vue/24/outline';
|
import {
|
||||||
|
ArrowDownOnSquareIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
} from '@heroicons/vue/24/outline';
|
||||||
|
import { canUseBillableRates } from '@/utils/billing';
|
||||||
|
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import type { ImportReport, ImportType } from '@/packages/api/src';
|
import type { ImportReport, ImportType } from '@/packages/api/src';
|
||||||
@@ -21,6 +26,8 @@ const { addNotification } = useNotificationsStore();
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const billableRatesLocked = computed(() => !canUseBillableRates());
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const organizationId = getCurrentOrganizationId();
|
const organizationId = getCurrentOrganizationId();
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
@@ -155,8 +162,19 @@ const showResultModal = ref(false);
|
|||||||
<div class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3">
|
<div class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3">
|
||||||
<InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
|
<InformationCircleIcon class="h-5 min-w-0 w-5 text-bg-tertiary" />
|
||||||
<p class="flex-1">
|
<p class="flex-1">
|
||||||
Import existing data from Toggl, Clockify or a different solidtime instance.
|
Import existing data from Toggl, Clockify, Harvest, generic CSV files or a
|
||||||
Please select the type of data you want to import and follow the instructions.
|
different solidtime instance. Please select the type of data you want to import
|
||||||
|
and follow the instructions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
class="py-2 px-3 sm:px-4 text-sm flex items-center space-x-3 border-t border-card-background-separator">
|
||||||
|
<ExclamationTriangleIcon class="h-5 min-w-0 w-5 text-yellow-500" />
|
||||||
|
<p class="flex-1">
|
||||||
|
Billable rates and billable status will not be imported, since they are not
|
||||||
|
available in the free plan. All projects and time entries will be imported as
|
||||||
|
non-billable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { storeToRefs } from 'pinia';
|
|||||||
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
import OrganizationBillableRateModal from '@/Components/Common/Organization/OrganizationBillableRateModal.vue';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { Checkbox } from '@/packages/ui/src';
|
import { Checkbox } from '@/packages/ui/src';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
|
|
||||||
const store = useOrganizationStore();
|
const store = useOrganizationStore();
|
||||||
const { fetchOrganization, updateOrganization } = store;
|
const { fetchOrganization, updateOrganization } = store;
|
||||||
const { organization } = storeToRefs(store);
|
const { organization } = storeToRefs(store);
|
||||||
|
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const organizationBody = ref<UpdateOrganizationBody>({
|
const organizationBody = ref<UpdateOrganizationBody>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -75,9 +77,8 @@ function checkForConfirmationModal() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="organization"
|
v-if="organization"
|
||||||
id="organizationShowBillableRatesToEmployees"
|
id="organizationShowBillableRatesToEmployees"
|
||||||
v-model:checked="
|
v-model:checked="organizationBody.employees_can_see_billable_rates"
|
||||||
organizationBody.employees_can_see_billable_rates
|
:disabled="billableRatesLocked"></Checkbox>
|
||||||
"></Checkbox>
|
|
||||||
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
||||||
>Show Billable Rates to Employees</FieldLabel
|
>Show Billable Rates to Employees</FieldLabel
|
||||||
>
|
>
|
||||||
@@ -85,7 +86,10 @@ function checkForConfirmationModal() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
|
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
|
||||||
|
>Upgrade</PrimaryButton
|
||||||
|
>
|
||||||
|
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||||
</template>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
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 OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||||
|
import OrganizationTrial from '@/Pages/Teams/Partials/OrganizationTrial.vue';
|
||||||
|
import { isBillingActivated, isInTrial } from '@/utils/billing';
|
||||||
|
import { canManageBilling } from '@/utils/permissions';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
@@ -52,6 +55,11 @@ onMounted(async () => {
|
|||||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
<SectionBorder v-if="permissions.canUpdateTeam" />
|
||||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||||
|
|
||||||
|
<template v-if="isBillingActivated() && isInTrial() && canManageBilling()">
|
||||||
|
<SectionBorder />
|
||||||
|
<OrganizationTrial />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="permissions.canDeleteTeam">
|
<template v-if="permissions.canDeleteTeam">
|
||||||
<SectionBorder />
|
<SectionBorder />
|
||||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ const IntervalFormat = z.enum([
|
|||||||
const TimeFormat = z.enum(['12-hours', '24-hours']);
|
const TimeFormat = z.enum(['12-hours', '24-hours']);
|
||||||
const OrganizationResource = z
|
const OrganizationResource = z
|
||||||
.object({
|
.object({
|
||||||
|
can_use_billable_rates: z.boolean().optional(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
is_personal: z.boolean(),
|
is_personal: z.boolean(),
|
||||||
@@ -5069,6 +5070,45 @@ If the group parameters are all set to `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',
|
method: 'get',
|
||||||
path: '/v1/public/reports',
|
path: '/v1/public/reports',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput,
|
NumberFieldInput,
|
||||||
} from '..';
|
} from '..';
|
||||||
|
import UpgradeLockedField from './UpgradeLockedField.vue';
|
||||||
|
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +22,8 @@ const model = defineModel<number | null>({
|
|||||||
default: null,
|
default: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { locked } = useBillableRatesLock();
|
||||||
|
|
||||||
const billableRateInput = ref<HTMLInputElement | null>(null);
|
const billableRateInput = ref<HTMLInputElement | null>(null);
|
||||||
useFocus(billableRateInput, { initialValue: props.focus });
|
useFocus(billableRateInput, { initialValue: props.focus });
|
||||||
|
|
||||||
@@ -30,7 +34,9 @@ function formatValue(modelValue: number | null) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
<UpgradeLockedField v-if="locked" />
|
||||||
<NumberField
|
<NumberField
|
||||||
|
v-else
|
||||||
:id="name"
|
:id="name"
|
||||||
ref="billableRateInput"
|
ref="billableRateInput"
|
||||||
:model-value="formatValue(model)"
|
:model-value="formatValue(model)"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -10,7 +11,13 @@ import {
|
|||||||
} from '@/packages/ui/src/tooltip';
|
} from '@/packages/ui/src/tooltip';
|
||||||
const active = defineModel<boolean>({ default: false });
|
const active = defineModel<boolean>({ default: false });
|
||||||
const emit = defineEmits(['changed']);
|
const emit = defineEmits(['changed']);
|
||||||
|
const { locked: billableRatesLocked, requestUpgrade } = useBillableRatesLock();
|
||||||
function toggleBillable() {
|
function toggleBillable() {
|
||||||
|
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||||
|
if (billableRatesLocked.value && !active.value) {
|
||||||
|
requestUpgrade();
|
||||||
|
return;
|
||||||
|
}
|
||||||
active.value = !active.value;
|
active.value = !active.value;
|
||||||
emit('changed', active.value);
|
emit('changed', active.value);
|
||||||
}
|
}
|
||||||
@@ -26,7 +33,12 @@ const props = withDefaults(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const tooltipLabel = computed(() => (active.value ? 'Billable' : 'Non Billable'));
|
const tooltipLabel = computed(() => {
|
||||||
|
if (billableRatesLocked.value && !active.value) {
|
||||||
|
return 'Billable rates require an upgrade';
|
||||||
|
}
|
||||||
|
return active.value ? 'Billable' : 'Non Billable';
|
||||||
|
});
|
||||||
|
|
||||||
const iconColorClasses = computed(() => {
|
const iconColorClasses = computed(() => {
|
||||||
if (active.value) {
|
if (active.value) {
|
||||||
|
|||||||
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';
|
} from '@/packages/ui/src/tooltip';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currency: string;
|
currency: string;
|
||||||
organizationBillableRate: number | null;
|
organizationBillableRate: number | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
type RateType = 'default-rate' | 'custom-rate';
|
type RateType = 'default-rate' | 'custom-rate';
|
||||||
|
|
||||||
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
|
||||||
const rateType = ref<RateType>('default-rate');
|
const rateType = ref<RateType>('default-rate');
|
||||||
|
const originallyBillable = ref(false);
|
||||||
|
const billableFlagLocked = computed(() => billableRatesLocked.value && !originallyBillable.value);
|
||||||
|
|
||||||
const billableRate = defineModel<number | null>('billableRate');
|
const billableRate = defineModel<number | null>('billableRate');
|
||||||
const isBillable = defineModel<boolean>('isBillable');
|
const isBillable = defineModel<boolean>('isBillable');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
originallyBillable.value = isBillable.value === true;
|
||||||
if (isBillable.value === true) {
|
if (isBillable.value === true) {
|
||||||
billableDefault.value = 'billable';
|
billableDefault.value = 'billable';
|
||||||
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
rateType.value = billableRate.value ? 'custom-rate' : 'default-rate';
|
||||||
@@ -66,6 +72,9 @@ const displayedRate = computed({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const billableDescription = computed(() => {
|
const billableDescription = computed(() => {
|
||||||
|
if (billableFlagLocked.value) {
|
||||||
|
return 'Your organization’s plan does not allow enabling billable projects.';
|
||||||
|
}
|
||||||
if (billableDefault.value === 'non-billable') {
|
if (billableDefault.value === 'non-billable') {
|
||||||
return 'New time entries for this project will not be marked billable by default.';
|
return 'New time entries for this project will not be marked billable by default.';
|
||||||
}
|
}
|
||||||
@@ -84,14 +93,19 @@ const emit = defineEmits(['submit']);
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="non-billable">Non-billable</SelectItem>
|
<SelectItem value="non-billable">Non-billable</SelectItem>
|
||||||
<SelectItem value="billable">Billable</SelectItem>
|
<SelectItem value="billable" :disabled="billableFlagLocked">Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
<FieldDescription>{{ billableDescription }}</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<BillableRateInput
|
||||||
|
v-if="billableRatesLocked"
|
||||||
|
v-model="displayedRate"
|
||||||
|
:currency="currency"
|
||||||
|
name="billableRate" />
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
<Select v-model="rateType">
|
<Select v-model="rateType">
|
||||||
<SelectTrigger id="billableRateType">
|
<SelectTrigger id="billableRateType">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -189,7 +191,9 @@ const billableProxy = computed({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="true">Billable</SelectItem>
|
<SelectItem value="true" :disabled="billableRatesLocked"
|
||||||
|
>Billable</SelectItem
|
||||||
|
>
|
||||||
<SelectItem value="false">Non Billable</SelectItem>
|
<SelectItem value="false">Non Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
} from '@/packages/api/src';
|
} from '@/packages/api/src';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||||
@@ -64,6 +65,7 @@ watch(show, (value) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const editableTimeEntry = ref<TimeEntry | null>(null);
|
const editableTimeEntry = ref<TimeEntry | null>(null);
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.timeEntry,
|
() => props.timeEntry,
|
||||||
@@ -134,6 +136,11 @@ async function deleteEntry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Organizations with locked billable rates can not make a non-billable time entry billable
|
||||||
|
const billableOptionLocked = computed(
|
||||||
|
() => billableRatesLocked.value && !props.timeEntry?.billable
|
||||||
|
);
|
||||||
|
|
||||||
const billableProxy = computed({
|
const billableProxy = computed({
|
||||||
get: () =>
|
get: () =>
|
||||||
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
|
||||||
@@ -245,7 +252,9 @@ const typeProxy = computed({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="true">Billable</SelectItem>
|
<SelectItem value="true" :disabled="billableOptionLocked"
|
||||||
|
>Billable</SelectItem
|
||||||
|
>
|
||||||
<SelectItem value="false">Non Billable</SelectItem>
|
<SelectItem value="false">Non Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ import { XMarkIcon } from '@heroicons/vue/16/solid';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||||
import { Button } from '@/packages/ui/src/Buttons';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||||
|
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
||||||
import type { Tag, Task } from '@/packages/api/src';
|
import type { Tag, Task } from '@/packages/api/src';
|
||||||
|
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
|
// Organizations with locked billable rates can remove the billable flag, but not set it
|
||||||
|
const { locked: billableRatesLocked } = useBillableRatesLock();
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -263,7 +266,9 @@ const showBreakWarning = computed(
|
|||||||
<SelectItem value="do-not-update">
|
<SelectItem value="do-not-update">
|
||||||
Keep current billable status
|
Keep current billable status
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="billable">Billable</SelectItem>
|
<SelectItem value="billable" :disabled="billableRatesLocked"
|
||||||
|
>Billable</SelectItem
|
||||||
|
>
|
||||||
<SelectItem value="non-billable">Non Billable</SelectItem>
|
<SelectItem value="non-billable">Non Billable</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
import UpgradeModal from './UpgradeModal.vue';
|
||||||
|
import type { UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
// The Professional feature behind the badge, picks the variant of the upgrade modal
|
||||||
|
feature: UpgradeFeature;
|
||||||
|
}>();
|
||||||
|
|
||||||
const showUpgradeModal = ref(false);
|
const showUpgradeModal = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UpgradeModal v-model:show="showUpgradeModal">
|
<UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
|
||||||
<slot>
|
|
||||||
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
|
|
||||||
</slot>
|
|
||||||
</UpgradeModal>
|
|
||||||
<button
|
<button
|
||||||
|
data-testid="upgrade_badge"
|
||||||
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
||||||
@click.prevent="showUpgradeModal = true">
|
@click.prevent="showUpgradeModal = true">
|
||||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||||
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 type { ActivityPeriod } from './FullCalendar/activityTypes';
|
||||||
export { cn } from './utils/cn';
|
export { cn } from './utils/cn';
|
||||||
export { useCssVariable } from './utils/useCssVariable';
|
export { useCssVariable } from './utils/useCssVariable';
|
||||||
|
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
|
||||||
|
export type { BillableRatesLock } from './utils/useBillableRatesLock';
|
||||||
|
export { upgradeCtaKey, useUpgradeCta } from './UpgradeModal/useUpgradeCta';
|
||||||
|
export type { UpgradeCta, UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||||
|
|
||||||
import Badge from './Badge.vue';
|
import Badge from './Badge.vue';
|
||||||
import Button from './Buttons/Button.vue';
|
import Button from './Buttons/Button.vue';
|
||||||
@@ -162,6 +166,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs/index';
|
|||||||
import TabBar from './TabBar/TabBar.vue';
|
import TabBar from './TabBar/TabBar.vue';
|
||||||
import TabBarItem from './TabBar/TabBarItem.vue';
|
import TabBarItem from './TabBar/TabBarItem.vue';
|
||||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||||
|
import UpgradeBadge from './UpgradeBadge.vue';
|
||||||
|
import UpgradeLockedBadge from './UpgradeLockedBadge.vue';
|
||||||
|
import UpgradeLockedValue from './UpgradeLockedValue.vue';
|
||||||
|
import UpgradeModal from './UpgradeModal.vue';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -305,4 +313,8 @@ export {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
|
UpgradeBadge,
|
||||||
|
UpgradeLockedBadge,
|
||||||
|
UpgradeLockedValue,
|
||||||
|
UpgradeModal,
|
||||||
};
|
};
|
||||||
|
|||||||
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())
|
(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: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export function useCopyLastWeek(
|
|||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
|
|
||||||
if (attempted === 0) return;
|
if (attempted === 0) return;
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export function useTimesheetCellMutations(
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
canCreateInvitations,
|
canCreateInvitations,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query';
|
||||||
|
import { fetchCanCreateMoreGoals } from '@/utils/useGoalsQuery';
|
||||||
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
import { useTimeEntriesInfiniteQuery } from '@/utils/useTimeEntriesInfiniteQuery';
|
||||||
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
import { useProjectsQuery } from '@/utils/useProjectsQuery';
|
||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
@@ -66,6 +68,7 @@ const showCreateClientModal = ref(false);
|
|||||||
const showCreateTaskModal = ref(false);
|
const showCreateTaskModal = ref(false);
|
||||||
const showCreateTagModal = ref(false);
|
const showCreateTagModal = ref(false);
|
||||||
const showCreateGoalModal = ref(false);
|
const showCreateGoalModal = ref(false);
|
||||||
|
const showGoalUpgradeModal = ref(false);
|
||||||
const showInviteMemberModal = ref(false);
|
const showInviteMemberModal = ref(false);
|
||||||
const showCreateTimeEntryModal = ref(false);
|
const showCreateTimeEntryModal = ref(false);
|
||||||
|
|
||||||
@@ -103,6 +106,7 @@ const ENTITY_ICONS: Record<string, typeof FolderIcon> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useCommandPalette() {
|
export function useCommandPalette() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
const currentTimeEntryStore = useCurrentTimeEntryStore();
|
||||||
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
|
const { currentTimeEntry, isActive } = storeToRefs(currentTimeEntryStore);
|
||||||
const { setActiveState, updateTimer } = currentTimeEntryStore;
|
const { setActiveState, updateTimer } = currentTimeEntryStore;
|
||||||
@@ -240,9 +244,13 @@ export function useCommandPalette() {
|
|||||||
showCreateTagModal.value = true;
|
showCreateTagModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateGoalModal() {
|
async function openCreateGoalModal() {
|
||||||
closePaletteAfterAction();
|
closePaletteAfterAction();
|
||||||
showCreateGoalModal.value = true;
|
if (await fetchCanCreateMoreGoals(queryClient)) {
|
||||||
|
showCreateGoalModal.value = true;
|
||||||
|
} else {
|
||||||
|
showGoalUpgradeModal.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInviteMemberModal() {
|
function openInviteMemberModal() {
|
||||||
@@ -525,6 +533,7 @@ export function useCommandPalette() {
|
|||||||
showCreateTaskModal,
|
showCreateTaskModal,
|
||||||
showCreateTagModal,
|
showCreateTagModal,
|
||||||
showCreateGoalModal,
|
showCreateGoalModal,
|
||||||
|
showGoalUpgradeModal,
|
||||||
showInviteMemberModal,
|
showInviteMemberModal,
|
||||||
showCreateTimeEntryModal,
|
showCreateTimeEntryModal,
|
||||||
showProjectSelector,
|
showProjectSelector,
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
|
|||||||
stopLiveTimer();
|
stopLiveTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Failed to fetch current time entry because organization ID is missing.'
|
'Failed to fetch current time entry because organization ID is missing.'
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/vue-query';
|
||||||
import { api } from '@/packages/api/src';
|
import { api } from '@/packages/api/src';
|
||||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||||
import type { Goal } from '@/packages/api/src';
|
import type { Goal } from '@/packages/api/src';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { fetchAllPages } from '@/utils/fetchAllPages';
|
import { fetchAllPages } from '@/utils/fetchAllPages';
|
||||||
|
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
||||||
|
|
||||||
export type GoalArchivedFilter = 'true' | 'false' | 'all';
|
export type GoalArchivedFilter = 'true' | 'false' | 'all';
|
||||||
|
|
||||||
@@ -19,19 +20,38 @@ export async function fetchAllGoals(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
|
export function goalsQueryOptions(archived: GoalArchivedFilter = 'false') {
|
||||||
const queryClient = useQueryClient();
|
return {
|
||||||
|
queryKey: ['goals', getCurrentOrganizationId(), archived],
|
||||||
const query = useQuery({
|
|
||||||
queryKey: computed(() => ['goals', getCurrentOrganizationId(), archived]),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const organizationId = getCurrentOrganizationId();
|
const organizationId = getCurrentOrganizationId();
|
||||||
if (!organizationId) throw new Error('No organization');
|
if (!organizationId) throw new Error('No organization');
|
||||||
const data = await fetchAllGoals(organizationId, archived);
|
const data = await fetchAllGoals(organizationId, archived);
|
||||||
return { data };
|
return { data };
|
||||||
},
|
},
|
||||||
enabled: () => !!getCurrentOrganizationId(),
|
|
||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the goal limit outside of the goals page, where the goals are not loaded yet.
|
||||||
|
* Archived goals count towards the limit, so all goals are counted.
|
||||||
|
*/
|
||||||
|
export async function fetchCanCreateMoreGoals(queryClient: QueryClient): Promise<boolean> {
|
||||||
|
if (goalLimit() === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const { data } = await queryClient.fetchQuery(goalsQueryOptions('all'));
|
||||||
|
return canCreateMoreGoals(data.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGoalsQuery(archived: GoalArchivedFilter = 'false') {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
...goalsQueryOptions(archived),
|
||||||
|
queryKey: computed(() => goalsQueryOptions(archived).queryKey),
|
||||||
|
enabled: () => !!getCurrentOrganizationId(),
|
||||||
// Progress is computed server side, keep it fresh while the page is open
|
// Progress is computed server side, keep it fresh while the page is open
|
||||||
refetchInterval: 1000 * 60,
|
refetchInterval: 1000 * 60,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function useTimeEntriesMutations() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function useTimeEntriesMutations() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ export function useTimeEntriesMutations() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,6 +130,7 @@ export function useTimeEntriesMutations() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +156,7 @@ export function useTimeEntriesMutations() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ abstract class TestCase extends BaseTestCase
|
|||||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
|
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,18 @@ abstract class TestCase extends BaseTestCase
|
|||||||
// Core has no goal limit, the limit of the free plan is implemented in the billing extension
|
// Core has no goal limit, the limit of the free plan is implemented in the billing extension
|
||||||
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
|
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function actAsOrganizationWithoutBillableRates(): void
|
||||||
|
{
|
||||||
|
$this->mock(BillingContract::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('hasSubscription')->andReturn(false);
|
||||||
|
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||||
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
|
$mock->shouldReceive('canUseBillableRates')->andReturn(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,4 +300,20 @@ class ChartEndpointTest extends EndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'charts:view:own',
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1155,4 +1155,56 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'message' => 'The given user is not a placeholder',
|
'message' => 'The given user is not a placeholder',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_member_update_ignores_billable_rate_but_updates_role_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'members:update',
|
||||||
|
]);
|
||||||
|
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create([
|
||||||
|
'billable' => true,
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
|
||||||
|
'billable_rate' => 20000,
|
||||||
|
'role' => Role::Manager->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$member->refresh();
|
||||||
|
$this->assertSame(10000, $member->billable_rate);
|
||||||
|
$this->assertSame(Role::Manager->value, $member->role);
|
||||||
|
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_member_update_does_not_remove_billable_rate_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'members:update',
|
||||||
|
]);
|
||||||
|
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->role(Role::Employee)->create([
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
|
||||||
|
'billable_rate' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertSame(10000, $member->refresh()->billable_rate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Events\AfterCreateOrganization;
|
|||||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\TimeEntry;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use App\Service\IpLookup\IpLookupResponseDto;
|
use App\Service\IpLookup\IpLookupResponseDto;
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
@@ -567,4 +568,85 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'id' => $data->organization->getKey(),
|
'id' => $data->organization->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_organization_update_ignores_billable_rate_and_employee_visibility_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'organizations:update',
|
||||||
|
]);
|
||||||
|
$data->organization->billable_rate = 10000;
|
||||||
|
$data->organization->employees_can_see_billable_rates = false;
|
||||||
|
$data->organization->save();
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
|
||||||
|
'billable' => true,
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||||
|
'name' => 'Organization',
|
||||||
|
'billable_rate' => 20000,
|
||||||
|
]);
|
||||||
|
$responseRemoveRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||||
|
'name' => 'Organization',
|
||||||
|
'billable_rate' => null,
|
||||||
|
]);
|
||||||
|
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||||
|
'name' => 'Renamed organization',
|
||||||
|
'employees_can_see_billable_rates' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseRate->assertOk();
|
||||||
|
$responseRemoveRate->assertOk();
|
||||||
|
$responseVisibility->assertOk();
|
||||||
|
$responseVisibility->assertJsonPath('data.employees_can_see_billable_rates', false);
|
||||||
|
$data->organization->refresh();
|
||||||
|
$this->assertSame('Renamed organization', $data->organization->name);
|
||||||
|
$this->assertSame(10000, $data->organization->billable_rate);
|
||||||
|
$this->assertFalse($data->organization->employees_can_see_billable_rates);
|
||||||
|
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_organization_update_can_remove_employee_visibility_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'organizations:update',
|
||||||
|
]);
|
||||||
|
$data->organization->employees_can_see_billable_rates = true;
|
||||||
|
$data->organization->save();
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
||||||
|
'name' => 'Organization',
|
||||||
|
'employees_can_see_billable_rates' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertFalse($data->organization->refresh()->employees_can_see_billable_rates);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_organization_exposes_billable_rate_eligibility_for_api_clients(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'organizations:view',
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonPath('data.can_use_billable_rates', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1426,6 +1426,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_keeps_billable_rate_if_it_is_not_part_of_the_request(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:update',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create([
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->assertBillableRateServiceIsUnused();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => 'Renamed',
|
||||||
|
'color' => $project->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => $project->is_billable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$project->refresh();
|
||||||
|
$this->assertSame('Renamed', $project->name);
|
||||||
|
$this->assertSame(10000, $project->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_can_archive_a_project(): void
|
public function test_update_endpoint_can_archive_a_project(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -1597,4 +1624,174 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'id' => $projectMember->getKey(),
|
'id' => $projectMember->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_project_store_ignores_billable_flag_and_rate_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:create',
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
|
'name' => 'Project',
|
||||||
|
'color' => '#ef5350',
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertCreated();
|
||||||
|
$response->assertJsonPath('data.is_billable', false);
|
||||||
|
$response->assertJsonPath('data.billable_rate', null);
|
||||||
|
$project = Project::query()->findOrFail($response->json('data.id'));
|
||||||
|
$this->assertFalse($project->is_billable);
|
||||||
|
$this->assertNull($project->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_store_sets_billable_flag_and_rate_if_organization_can_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:create',
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||||
|
'name' => 'Project',
|
||||||
|
'color' => '#ef5350',
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertCreated();
|
||||||
|
$response->assertJsonPath('data.is_billable', true);
|
||||||
|
$response->assertJsonPath('data.billable_rate', 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:update',
|
||||||
|
]);
|
||||||
|
$billableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
|
||||||
|
$removedBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => true]);
|
||||||
|
$nonBillableProject = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $billableProject->getKey()]), [
|
||||||
|
'name' => 'Still billable',
|
||||||
|
'color' => $billableProject->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
]);
|
||||||
|
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $removedBillableProject->getKey()]), [
|
||||||
|
'name' => 'No longer billable',
|
||||||
|
'color' => $removedBillableProject->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => false,
|
||||||
|
]);
|
||||||
|
$responseSet = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $nonBillableProject->getKey()]), [
|
||||||
|
'name' => 'Not billable',
|
||||||
|
'color' => $nonBillableProject->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseKeep->assertOk();
|
||||||
|
$responseKeep->assertJsonPath('data.is_billable', true);
|
||||||
|
$responseRemove->assertOk();
|
||||||
|
$responseRemove->assertJsonPath('data.is_billable', false);
|
||||||
|
$responseSet->assertOk();
|
||||||
|
$responseSet->assertJsonPath('data.is_billable', false);
|
||||||
|
$this->assertTrue($billableProject->refresh()->is_billable);
|
||||||
|
$this->assertFalse($removedBillableProject->refresh()->is_billable);
|
||||||
|
$nonBillableProject->refresh();
|
||||||
|
$this->assertFalse($nonBillableProject->is_billable);
|
||||||
|
$this->assertSame('Not billable', $nonBillableProject->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_update_can_enable_billable_flag_if_organization_can_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:update',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create(['is_billable' => false]);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => $project->name,
|
||||||
|
'color' => $project->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertTrue($project->refresh()->is_billable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_update_keeps_existing_billable_rate_and_time_entry_rates_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'projects:update',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create([
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
'is_billable' => true,
|
||||||
|
]);
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->create([
|
||||||
|
'billable' => true,
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => 'Changed rate',
|
||||||
|
'color' => $project->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
'billable_rate' => 20000,
|
||||||
|
]);
|
||||||
|
// Note: Organizations that can not use billable rates do not receive the rate of the project, so clients send `null` back
|
||||||
|
$responseNull = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => 'Hidden rate',
|
||||||
|
'color' => $project->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
'billable_rate' => null,
|
||||||
|
]);
|
||||||
|
$responseOmitted = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'name' => 'Omitted rate',
|
||||||
|
'color' => $project->color,
|
||||||
|
'client_id' => null,
|
||||||
|
'is_billable' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseChange->assertOk();
|
||||||
|
$responseNull->assertOk();
|
||||||
|
$responseOmitted->assertOk();
|
||||||
|
$project->refresh();
|
||||||
|
$this->assertSame('Omitted rate', $project->name);
|
||||||
|
$this->assertSame(10000, $project->billable_rate);
|
||||||
|
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,4 +501,40 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'id' => $projectMember->getKey(),
|
'id' => $projectMember->getKey(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_project_member_store_and_update_ignore_billable_rate_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create();
|
||||||
|
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseStore = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$responseUpdate = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
|
||||||
|
'billable_rate' => 20000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseStore->assertCreated();
|
||||||
|
$responseStore->assertJsonPath('data.billable_rate', null);
|
||||||
|
$responseUpdate->assertOk();
|
||||||
|
$responseUpdate->assertJsonPath('data.billable_rate', 10000);
|
||||||
|
$this->assertSame(10000, $projectMember->refresh()->billable_rate);
|
||||||
|
$this->assertDatabaseHas(ProjectMember::class, [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
'billable_rate' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Models\Tag;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\BillingContract;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -35,6 +36,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
use Illuminate\Testing\Fluent\AssertableJson;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
use Mockery\MockInterface;
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
use Ramsey\Uuid\Type\Time;
|
use Ramsey\Uuid\Type\Time;
|
||||||
use TiMacDonald\Log\LogEntry;
|
use TiMacDonald\Log\LogEntry;
|
||||||
@@ -6096,4 +6098,291 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'billable_rate' => null,
|
'billable_rate' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:view:all',
|
||||||
|
]);
|
||||||
|
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
|
||||||
|
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($start, 3600)->create([
|
||||||
|
'billable' => true,
|
||||||
|
'billable_rate' => 10000,
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||||
|
$data->organization->getKey(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertJsonPath('data.seconds', 3600);
|
||||||
|
$response->assertJsonPath('data.cost', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_store_creates_non_billable_time_entry_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
$data->organization->billable_rate = 10000;
|
||||||
|
$data->organization->save();
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertCreated();
|
||||||
|
$response->assertJsonPath('data.billable', false);
|
||||||
|
$timeEntry = TimeEntry::query()->findOrFail($response->json('data.id'));
|
||||||
|
$this->assertFalse($timeEntry->billable);
|
||||||
|
$this->assertNull($timeEntry->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_store_creates_billable_time_entry_if_organization_can_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
$this->actAsOrganizationWithSubscription();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertCreated();
|
||||||
|
$response->assertJsonPath('data.billable', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_update_keeps_or_removes_existing_billable_flag_but_does_not_set_new_one_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(7), 3600)->create();
|
||||||
|
$removedBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
|
||||||
|
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseKeep = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'description' => 'Still billable',
|
||||||
|
]);
|
||||||
|
$responseRemove = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $removedBillableTimeEntry->getKey()]), [
|
||||||
|
'billable' => false,
|
||||||
|
]);
|
||||||
|
$responseSet = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'description' => 'Not billable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseKeep->assertOk();
|
||||||
|
$responseKeep->assertJsonPath('data.billable', true);
|
||||||
|
$responseRemove->assertOk();
|
||||||
|
$responseRemove->assertJsonPath('data.billable', false);
|
||||||
|
$responseSet->assertOk();
|
||||||
|
$responseSet->assertJsonPath('data.billable', false);
|
||||||
|
$this->assertTrue($billableTimeEntry->refresh()->billable);
|
||||||
|
$this->assertSame('Still billable', $billableTimeEntry->description);
|
||||||
|
$this->assertFalse($removedBillableTimeEntry->refresh()->billable);
|
||||||
|
$nonBillableTimeEntry->refresh();
|
||||||
|
$this->assertFalse($nonBillableTimeEntry->billable);
|
||||||
|
$this->assertSame('Not billable', $nonBillableTimeEntry->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_update_multiple_keeps_existing_billable_flags_but_does_not_set_new_ones_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$data->organization->billable_rate = 10000;
|
||||||
|
$data->organization->save();
|
||||||
|
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create();
|
||||||
|
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()->create();
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||||
|
'ids' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
|
||||||
|
'changes' => [
|
||||||
|
'billable' => true,
|
||||||
|
'description' => 'Mass update',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertExactJson([
|
||||||
|
'success' => [$billableTimeEntry->getKey(), $nonBillableTimeEntry->getKey()],
|
||||||
|
'error' => [],
|
||||||
|
]);
|
||||||
|
$billableTimeEntry->refresh();
|
||||||
|
$this->assertTrue($billableTimeEntry->billable);
|
||||||
|
$this->assertSame('Mass update', $billableTimeEntry->description);
|
||||||
|
$nonBillableTimeEntry->refresh();
|
||||||
|
$this->assertFalse($nonBillableTimeEntry->billable);
|
||||||
|
$this->assertNull($nonBillableTimeEntry->billable_rate);
|
||||||
|
$this->assertSame('Mass update', $nonBillableTimeEntry->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_update_multiple_can_remove_billable_flag_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()->create();
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||||
|
'ids' => [$timeEntry->getKey()],
|
||||||
|
'changes' => [
|
||||||
|
'billable' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertExactJson([
|
||||||
|
'success' => [$timeEntry->getKey()],
|
||||||
|
'error' => [],
|
||||||
|
]);
|
||||||
|
$timeEntry->refresh();
|
||||||
|
$this->assertFalse($timeEntry->billable);
|
||||||
|
$this->assertNull($timeEntry->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_update_with_project_change_does_not_make_time_entry_billable_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$project = Project::factory()->forOrganization($data->organization)->isPublic()->billable(10000)->create();
|
||||||
|
$otherProject = Project::factory()->forOrganization($data->organization)->isPublic()->billable(20000)->create();
|
||||||
|
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->billable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
|
||||||
|
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
|
||||||
|
'project_id' => $otherProject->getKey(),
|
||||||
|
'billable' => true,
|
||||||
|
]);
|
||||||
|
$responseNonBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
|
||||||
|
'project_id' => $otherProject->getKey(),
|
||||||
|
'billable' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseBillable->assertOk();
|
||||||
|
$responseNonBillable->assertOk();
|
||||||
|
$billableTimeEntry->refresh();
|
||||||
|
$this->assertSame($otherProject->getKey(), $billableTimeEntry->project_id);
|
||||||
|
$this->assertTrue($billableTimeEntry->billable);
|
||||||
|
$this->assertSame(20000, $billableTimeEntry->billable_rate);
|
||||||
|
$nonBillableTimeEntry->refresh();
|
||||||
|
$this->assertSame($otherProject->getKey(), $nonBillableTimeEntry->project_id);
|
||||||
|
$this->assertFalse($nonBillableTimeEntry->billable);
|
||||||
|
$this->assertNull($nonBillableTimeEntry->billable_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_store_does_not_check_billable_rates_for_non_billable_time_entry(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:create:own',
|
||||||
|
]);
|
||||||
|
$this->mockBillingWithoutBillableRateCheck();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||||
|
'billable' => false,
|
||||||
|
'start' => Carbon::now()->subHour()->toIso8601ZuluString(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertCreated();
|
||||||
|
$response->assertJsonPath('data.billable', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_time_entry_update_does_not_check_billable_rates_if_time_entry_does_not_become_billable(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$nonBillableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->notBillable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(5), 3600)->create();
|
||||||
|
$billableTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->billable()
|
||||||
|
->startWithDuration(Carbon::now()->subHours(3), 3600)->create();
|
||||||
|
$this->mockBillingWithoutBillableRateCheck();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$responseNonBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $nonBillableTimeEntry->getKey()]), [
|
||||||
|
'description' => 'Still not billable',
|
||||||
|
]);
|
||||||
|
$responseBillable = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $billableTimeEntry->getKey()]), [
|
||||||
|
'billable' => true,
|
||||||
|
'description' => 'Still billable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$responseNonBillable->assertOk();
|
||||||
|
$responseNonBillable->assertJsonPath('data.billable', false);
|
||||||
|
$responseBillable->assertOk();
|
||||||
|
$responseBillable->assertJsonPath('data.billable', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing that fails the test if it is asked whether the organization can use billable rates
|
||||||
|
*/
|
||||||
|
private function mockBillingWithoutBillableRateCheck(): void
|
||||||
|
{
|
||||||
|
$this->mock(BillingContract::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('hasSubscription')->andReturn(true);
|
||||||
|
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||||
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
|
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
||||||
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
|
$mock->shouldNotReceive('canUseBillableRates');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
$mock->shouldReceive('getGoalLimit')->andReturn(1);
|
$mock->shouldReceive('getGoalLimit')->andReturn(1);
|
||||||
|
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||||
});
|
});
|
||||||
Passport::actingAs($user->user);
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
->where('billing.trial_until', null)
|
->where('billing.trial_until', null)
|
||||||
->where('billing.is_blocked', false)
|
->where('billing.is_blocked', false)
|
||||||
->where('billing.goal_limit', 1)
|
->where('billing.goal_limit', 1)
|
||||||
|
->where('billing.can_use_billable_rates', true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
|
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
$mock->shouldReceive('getGoalLimit')->andReturn(null);
|
||||||
|
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
||||||
});
|
});
|
||||||
Passport::actingAs($user->user);
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
@@ -77,6 +80,7 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
|
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
|
||||||
->where('billing.is_blocked', false)
|
->where('billing.is_blocked', false)
|
||||||
->where('billing.goal_limit', null)
|
->where('billing.goal_limit', null)
|
||||||
|
->where('billing.can_use_billable_rates', true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,4 +157,26 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
|
|||||||
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
|
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$importer = new ClockifyProjectsImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_1.csv');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
|
||||||
|
$this->assertCount(2, $projects);
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$this->assertFalse($project->is_billable);
|
||||||
|
$this->assertNull($project->billable_rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,4 +256,26 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
|
|||||||
}
|
}
|
||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$importer = new ClockifyTimeEntriesImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_1.csv');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get();
|
||||||
|
$this->assertCount(2, $timeEntries);
|
||||||
|
foreach ($timeEntries as $timeEntry) {
|
||||||
|
$this->assertFalse($timeEntry->billable);
|
||||||
|
$this->assertNull($timeEntry->billable_rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,4 +82,26 @@ class GenericProjectsImporterTest extends ImporterTestAbstract
|
|||||||
$this->assertSame(3, $report->projectsCreated);
|
$this->assertSame(3, $report->projectsCreated);
|
||||||
$this->assertSame(2, $report->clientsCreated);
|
$this->assertSame(2, $report->clientsCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_import_drops_billable_flags_and_rates_if_organization_can_not_use_billable_rates(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->actAsOrganizationWithoutBillableRates();
|
||||||
|
$organization = Organization::factory()->create();
|
||||||
|
$timezone = 'Europe/Vienna';
|
||||||
|
$importer = new GenericProjectsImporter;
|
||||||
|
$importer->init($organization);
|
||||||
|
$data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$importer->importData($data, $timezone);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
|
||||||
|
$this->assertCount(3, $projects);
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$this->assertFalse($project->is_billable);
|
||||||
|
$this->assertNull($project->billable_rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user