mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
2 Commits
feature/bi
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78536dd1fa | ||
|
|
1de985b577 |
2
.github/workflows/build-private.yml
vendored
2
.github/workflows/build-private.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
|||||||
- name: "Install dependencies in billing extension"
|
- name: "Install dependencies in billing extension"
|
||||||
uses: php-actions/composer@v6
|
uses: php-actions/composer@v6
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
|
||||||
with:
|
with:
|
||||||
working_dir: "extensions/Billing"
|
working_dir: "extensions/Billing"
|
||||||
command: install
|
command: install
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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\DashboardService;
|
use App\Service\DashboardService;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
@@ -158,7 +159,7 @@ class ChartController extends Controller
|
|||||||
$this->checkPermission($organization, 'charts:view:own');
|
$this->checkPermission($organization, 'charts:view:own');
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
|
|
||||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +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\FeatureIsNotAvailableInFreePlanApiException;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillingContract;
|
use App\Service\BillingContract;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
@@ -51,30 +49,4 @@ 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function canSeeBillableRates(Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (! $this->canUseBillableRates($organization)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Organizations that can not use billable rates may keep or remove an existing rate, but not set a new one
|
|
||||||
*
|
|
||||||
* @throws FeatureIsNotAvailableInFreePlanApiException
|
|
||||||
*/
|
|
||||||
protected function checkBillableRateChange(Organization $organization, ?int $oldBillableRate, ?int $newBillableRate): void
|
|
||||||
{
|
|
||||||
if ($newBillableRate !== null && $newBillableRate !== $oldBillableRate && ! $this->canUseBillableRates($organization)) {
|
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ 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()) {
|
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
|
||||||
$this->checkBillableRateChange($organization, $member->billable_rate, $request->getBillableRate());
|
|
||||||
$member->billable_rate = $request->getBillableRate();
|
$member->billable_rate = $request->getBillableRate();
|
||||||
|
|
||||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||||
|
|||||||
@@ -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\Events\AfterCreateOrganization;
|
use App\Events\AfterCreateOrganization;
|
||||||
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
|
||||||
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;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
@@ -32,7 +32,7 @@ class OrganizationController extends Controller
|
|||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'organizations:view');
|
$this->checkPermission($organization, 'organizations:view');
|
||||||
|
|
||||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
return new OrganizationResource($organization, $showBillableRate);
|
return new OrganizationResource($organization, $showBillableRate);
|
||||||
}
|
}
|
||||||
@@ -54,10 +54,7 @@ class OrganizationController extends Controller
|
|||||||
if ($request->getCurrency() !== null) {
|
if ($request->getCurrency() !== null) {
|
||||||
$organization->currency = $request->getCurrency();
|
$organization->currency = $request->getCurrency();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) {
|
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||||
if (! $this->canUseBillableRates($organization)) {
|
|
||||||
throw new FeatureIsNotAvailableInFreePlanApiException;
|
|
||||||
}
|
|
||||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||||
}
|
}
|
||||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||||
@@ -86,7 +83,6 @@ class OrganizationController extends Controller
|
|||||||
}
|
}
|
||||||
$hasBillableRate = $request->has('billable_rate');
|
$hasBillableRate = $request->has('billable_rate');
|
||||||
if ($hasBillableRate) {
|
if ($hasBillableRate) {
|
||||||
$this->checkBillableRateChange($organization, $organization->billable_rate, $request->getBillableRate());
|
|
||||||
$oldBillableRate = $organization->billable_rate;
|
$oldBillableRate = $organization->billable_rate;
|
||||||
$organization->billable_rate = $request->getBillableRate();
|
$organization->billable_rate = $request->getBillableRate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,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->canSeeBillableRates($organization);
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
return new ProjectCollection($projects, $showBillableRate);
|
return new ProjectCollection($projects, $showBillableRate);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,6 @@ 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');
|
||||||
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
|
|
||||||
$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');
|
||||||
@@ -126,7 +125,6 @@ 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);
|
||||||
$this->checkBillableRateChange($organization, $project->billable_rate, $request->getBillableRate());
|
|
||||||
$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 = (bool) $request->input('is_billable');
|
||||||
@@ -141,17 +139,14 @@ class ProjectController extends Controller
|
|||||||
}
|
}
|
||||||
$oldBillableRate = $project->billable_rate;
|
$oldBillableRate = $project->billable_rate;
|
||||||
$clientIdChanged = false;
|
$clientIdChanged = false;
|
||||||
// Organizations that can not use billable rates do not receive the rate of the project, so the incoming value is ignored to keep the existing rate
|
$project->billable_rate = $request->getBillableRate();
|
||||||
if ($this->canUseBillableRates($organization)) {
|
|
||||||
$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 !== $project->billable_rate) {
|
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||||
}
|
}
|
||||||
if ($clientIdChanged) {
|
if ($clientIdChanged) {
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class ProjectMemberController extends Controller
|
|||||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
|
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:create', $project);
|
$this->checkPermission($organization, 'project-members:create', $project);
|
||||||
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
|
|
||||||
|
|
||||||
$member = Member::findOrFail((string) $request->input('member_id'));
|
$member = Member::findOrFail((string) $request->input('member_id'));
|
||||||
if ($member->user->is_placeholder) {
|
if ($member->user->is_placeholder) {
|
||||||
@@ -99,7 +98,6 @@ class ProjectMemberController extends Controller
|
|||||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||||
{
|
{
|
||||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||||
$this->checkBillableRateChange($organization, $projectMember->billable_rate, $request->getBillableRate());
|
|
||||||
$oldBillableRate = $projectMember->billable_rate;
|
$oldBillableRate = $projectMember->billable_rate;
|
||||||
$projectMember->billable_rate = $request->getBillableRate();
|
$projectMember->billable_rate = $request->getBillableRate();
|
||||||
$projectMember->save();
|
$projectMember->save();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -239,7 +240,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||||
|
|
||||||
@@ -385,7 +386,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
@@ -440,7 +441,7 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
$debug = $request->getDebug();
|
$debug = $request->getDebug();
|
||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$showBillableRate = $this->canSeeBillableRates($organization);
|
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||||
|
|
||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||||
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
|
||||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||||
'can_use_billable_rates' => $billing->canUseBillableRates($currentOrganization),
|
|
||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
|
|||||||
@@ -54,13 +54,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use App\Models\Tag;
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
use App\Service\BillingContract;
|
|
||||||
use App\Service\ColorService;
|
use App\Service\ColorService;
|
||||||
use App\Service\Import\ImportDatabaseHelper;
|
use App\Service\Import\ImportDatabaseHelper;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
@@ -72,15 +71,9 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
|
|
||||||
protected BillableRateService $billableRateService;
|
protected BillableRateService $billableRateService;
|
||||||
|
|
||||||
/**
|
|
||||||
* Organizations that can not use billable rates import projects, members and project members without billable rates
|
|
||||||
*/
|
|
||||||
protected bool $canUseBillableRates;
|
|
||||||
|
|
||||||
public function init(Organization $organization): void
|
public function init(Organization $organization): void
|
||||||
{
|
{
|
||||||
$this->organization = $organization;
|
$this->organization = $organization;
|
||||||
$this->canUseBillableRates = app(BillingContract::class)->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);
|
||||||
@@ -103,11 +96,7 @@ 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);
|
||||||
@@ -131,7 +120,7 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'uuid',
|
'uuid',
|
||||||
],
|
],
|
||||||
], beforeSave: function (Project $project): void {
|
], beforeSave: function (Project $project): void {
|
||||||
if ($project->billable_rate === 0 || ! $this->canUseBillableRates) {
|
if ($project->billable_rate === 0) {
|
||||||
$project->billable_rate = null;
|
$project->billable_rate = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -145,7 +134,7 @@ abstract class DefaultImporter implements ImporterContract
|
|||||||
'max:2147483647',
|
'max:2147483647',
|
||||||
],
|
],
|
||||||
], beforeSave: function (ProjectMember $projectMember): void {
|
], beforeSave: function (ProjectMember $projectMember): void {
|
||||||
if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) {
|
if ($projectMember->billable_rate === 0) {
|
||||||
$projectMember->billable_rate = null;
|
$projectMember->billable_rate = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
ARG PHP_VERSION=8.3
|
ARG PHP_VERSION=8.3
|
||||||
ARG FRANKENPHP_VERSION=1.8
|
ARG FRANKENPHP_VERSION=1.11
|
||||||
ARG COMPOSER_VERSION=2.8
|
ARG COMPOSER_VERSION=2.8
|
||||||
ARG BUN_VERSION="latest"
|
|
||||||
ARG APP_ENV
|
ARG APP_ENV
|
||||||
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
ARG DOCKER_FILES_BASE_PATH="docker/prod/"
|
||||||
|
|
||||||
@@ -16,13 +15,13 @@ RUN CGO_ENABLED=1 \
|
|||||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||||
CGO_CFLAGS=$(php-config --includes) \
|
CGO_CFLAGS=$(php-config --includes) \
|
||||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||||
xcaddy build v2.10.0 \
|
xcaddy build \
|
||||||
--output /usr/local/bin/frankenphp \
|
--output /usr/local/bin/frankenphp \
|
||||||
--with github.com/dunglas/frankenphp=./ \
|
--with github.com/dunglas/frankenphp=./ \
|
||||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||||
--with github.com/dunglas/caddy-cbrotli
|
--with github.com/dunglas/caddy-cbrotli
|
||||||
|
|
||||||
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
|
FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION}
|
||||||
|
|
||||||
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
|
||||||
|
|
||||||
@@ -32,26 +31,28 @@ LABEL org.opencontainers.image.description="solidtime is a modern open source ti
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
LABEL org.opencontainers.image.source="https://github.com/solidtime-io/solidtime"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL"
|
LABEL org.opencontainers.image.licenses="AGPL"
|
||||||
|
|
||||||
ARG WWWUSER=1000
|
ARG USER_ID=1000
|
||||||
ARG WWWGROUP=1000
|
ARG GROUP_ID=1000
|
||||||
ARG TZ=UTC
|
ARG TZ=UTC
|
||||||
ARG APP_DIR=/var/www/html
|
|
||||||
ARG APP_ENV
|
|
||||||
ARG APP_HOST
|
|
||||||
ARG DOCKER_FILES_BASE_PATH
|
ARG DOCKER_FILES_BASE_PATH
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
TERM=xterm-color \
|
TERM=xterm-color \
|
||||||
OCTANE_SERVER=frankenphp \
|
OCTANE_SERVER=frankenphp \
|
||||||
TZ=${TZ} \
|
TZ=${TZ} \
|
||||||
USER=octane \
|
LANG=C.UTF-8 \
|
||||||
ROOT=${APP_DIR} \
|
USER=laravel \
|
||||||
APP_ENV=${APP_ENV} \
|
ROOT=/var/www/html \
|
||||||
|
APP_ENV=production \
|
||||||
|
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||||
COMPOSER_FUND=0 \
|
COMPOSER_FUND=0 \
|
||||||
COMPOSER_MAX_PARALLEL_HTTP=24 \
|
COMPOSER_MAX_PARALLEL_HTTP=48 \
|
||||||
XDG_CONFIG_HOME=${APP_DIR}/.config \
|
WITH_HORIZON=false \
|
||||||
XDG_DATA_HOME=${APP_DIR}/.data \
|
WITH_SCHEDULER=false \
|
||||||
SERVER_NAME=${APP_HOST}
|
WITH_REVERB=false \
|
||||||
|
WITH_SSR=false
|
||||||
|
|
||||||
|
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
|
||||||
|
|
||||||
WORKDIR ${ROOT}
|
WORKDIR ${ROOT}
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ SHELL ["/bin/bash", "-eou", "pipefail", "-c"]
|
|||||||
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||||
&& echo ${TZ} > /etc/timezone
|
&& echo ${TZ} > /etc/timezone
|
||||||
|
|
||||||
|
RUN echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
||||||
|
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
||||||
|
|
||||||
RUN apt-get update; \
|
RUN apt-get update; \
|
||||||
apt-get upgrade -yqq; \
|
apt-get upgrade -yqq; \
|
||||||
apt-get install -yqq --no-install-recommends --show-progress \
|
apt-get install -yqq --no-install-recommends --show-progress \
|
||||||
@@ -68,40 +72,36 @@ RUN apt-get update; \
|
|||||||
wget \
|
wget \
|
||||||
vim \
|
vim \
|
||||||
git \
|
git \
|
||||||
|
unzip \
|
||||||
ncdu \
|
ncdu \
|
||||||
procps \
|
procps \
|
||||||
unzip \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
supervisor \
|
supervisor \
|
||||||
libsodium-dev \
|
libsodium-dev \
|
||||||
libbrotli-dev \
|
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \
|
||||||
# Install PHP extensions (included with dunglas/frankenphp)
|
|
||||||
&& install-php-extensions \
|
&& install-php-extensions \
|
||||||
|
apcu \
|
||||||
bz2 \
|
bz2 \
|
||||||
pcntl \
|
pcntl \
|
||||||
mbstring \
|
mbstring \
|
||||||
bcmath \
|
bcmath \
|
||||||
sockets \
|
sockets \
|
||||||
pgsql \
|
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
opcache \
|
opcache \
|
||||||
exif \
|
exif \
|
||||||
pdo_mysql \
|
pdo_mysql \
|
||||||
zip \
|
zip \
|
||||||
uv \
|
uv \
|
||||||
vips \
|
|
||||||
intl \
|
intl \
|
||||||
gd \
|
gd \
|
||||||
redis \
|
redis \
|
||||||
rdkafka \
|
rdkafka \
|
||||||
memcached \
|
ffi \
|
||||||
igbinary \
|
|
||||||
ldap \
|
ldap \
|
||||||
&& apt-get -y autoremove \
|
&& apt-get -y autoremove \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& docker-php-source delete \
|
&& docker-php-source delete \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/lastlog /var/log/faillog
|
||||||
&& rm /var/log/lastlog /var/log/faillog
|
|
||||||
|
|
||||||
RUN arch="$(uname -m)" \
|
RUN arch="$(uname -m)" \
|
||||||
&& case "$arch" in \
|
&& case "$arch" in \
|
||||||
@@ -111,106 +111,64 @@ RUN arch="$(uname -m)" \
|
|||||||
x86) _cronic_fname='supercronic-linux-386' ;; \
|
x86) _cronic_fname='supercronic-linux-386' ;; \
|
||||||
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
*) echo >&2 "error: unsupported architecture: $arch"; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_cronic_fname}" \
|
&& wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.38/${_cronic_fname}" \
|
||||||
-O /usr/bin/supercronic \
|
-O /usr/bin/supercronic \
|
||||||
&& chmod +x /usr/bin/supercronic \
|
&& chmod +x /usr/bin/supercronic \
|
||||||
&& mkdir -p /etc/supercronic \
|
&& mkdir -p /etc/supercronic \
|
||||||
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --no-interaction" > /etc/supercronic/laravel
|
||||||
|
|
||||||
RUN userdel --remove --force www-data \
|
RUN userdel --remove --force www-data \
|
||||||
&& groupadd --force -g ${WWWGROUP} ${USER} \
|
&& groupadd --force -g ${GROUP_ID} ${USER} \
|
||||||
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${USER} \
|
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER}
|
||||||
&& setcap -r /usr/local/bin/frankenphp
|
|
||||||
|
|
||||||
RUN chown -R ${USER}:${USER} ${ROOT} /var/{log,run} \
|
|
||||||
&& chmod -R a+rw ${ROOT} /var/{log,run}
|
|
||||||
|
|
||||||
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
RUN cp ${PHP_INI_DIR}/php.ini-production ${PHP_INI_DIR}/php.ini
|
||||||
|
|
||||||
USER ${USER}
|
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
||||||
|
COPY --link ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-php.ini
|
||||||
|
#COPY --link composer.* ./
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} --from=vendor /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.conf /etc/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/octane/FrankenPHP/supervisord.frankenphp.conf /etc/supervisor/conf.d/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/supervisord.*.conf /etc/supervisor/conf.d/
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/start-container /usr/local/bin/start-container
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/healthcheck /usr/local/bin/healthcheck
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} ${DOCKER_FILES_BASE_PATH}deployment/php.ini ${PHP_INI_DIR}/conf.d/99-octane.ini
|
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
|
||||||
|
|
||||||
###########################################
|
|
||||||
|
|
||||||
#FROM base AS common
|
|
||||||
#
|
|
||||||
#USER ${USER}
|
|
||||||
#
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
|
||||||
#
|
|
||||||
#RUN composer install \
|
#RUN composer install \
|
||||||
# --no-dev \
|
# --no-dev \
|
||||||
# --no-interaction \
|
# --no-interaction \
|
||||||
# --no-autoloader \
|
# --no-autoloader \
|
||||||
# --no-ansi \
|
# --no-ansi \
|
||||||
# --no-scripts \
|
# --no-scripts \
|
||||||
|
# --no-progress \
|
||||||
# --audit
|
# --audit
|
||||||
|
|
||||||
###########################################
|
|
||||||
# Build frontend assets with Bun
|
|
||||||
###########################################
|
|
||||||
|
|
||||||
#FROM oven/bun:${BUN_VERSION} AS build
|
|
||||||
#
|
|
||||||
#ARG APP_ENV
|
|
||||||
#
|
|
||||||
#ENV ROOT=/var/www/html \
|
|
||||||
# APP_ENV=${APP_ENV} \
|
|
||||||
# NODE_ENV=${APP_ENV:-production}
|
|
||||||
#
|
|
||||||
#WORKDIR ${ROOT}
|
|
||||||
#
|
|
||||||
#COPY --link package.json bun.lock* ./
|
#COPY --link package.json bun.lock* ./
|
||||||
#
|
|
||||||
#RUN bun install --frozen-lockfile
|
#RUN bun install --frozen-lockfile
|
||||||
#
|
|
||||||
#COPY --link . .
|
|
||||||
#COPY --link --from=common ${ROOT}/vendor vendor
|
|
||||||
#
|
|
||||||
#RUN bun run build
|
|
||||||
|
|
||||||
###########################################
|
COPY --link . .
|
||||||
|
|
||||||
#FROM common AS runner
|
|
||||||
|
|
||||||
USER ${USER}
|
|
||||||
|
|
||||||
ENV WITH_HORIZON=false \
|
|
||||||
WITH_SCHEDULER=false \
|
|
||||||
WITH_REVERB=false
|
|
||||||
|
|
||||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
|
||||||
|
|
||||||
RUN test -z "$(find . -name .git -print -quit)"
|
|
||||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
storage/framework/{sessions,views,cache,testing} \
|
storage/framework/{sessions,views,cache,testing} \
|
||||||
storage/logs \
|
storage/logs \
|
||||||
bootstrap/cache && chmod -R a+rw storage
|
bootstrap/cache \
|
||||||
|
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
|
||||||
|
|
||||||
#RUN composer install \
|
RUN composer dump-autoload \
|
||||||
# --classmap-authoritative \
|
--optimize \
|
||||||
# --no-interaction \
|
--apcu \
|
||||||
# --no-ansi \
|
--no-dev
|
||||||
# --no-dev \
|
|
||||||
# && composer clear-cache
|
|
||||||
|
|
||||||
RUN cat .env
|
#RUN bun run build
|
||||||
#RUN php artisan env
|
|
||||||
|
RUN chown -R ${USER_ID}:${GROUP_ID} ${ROOT} \
|
||||||
|
&& find / -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
|
||||||
|
|
||||||
|
USER ${USER}
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
#EXPOSE 2019
|
||||||
|
#EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["start-container"]
|
ENTRYPOINT ["start-container"]
|
||||||
|
|
||||||
#HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1
|
#HEALTHCHECK --start-period=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ elif [ "${container_mode}" = "reverb" ]; then
|
|||||||
echo "Healthcheck failed."
|
echo "Healthcheck failed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
elif [ "${container_mode}" = "ssr" ]; then
|
||||||
|
if [ "$(supervisorctl status inertia-ssr-server:inertia-ssr-server_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Healthcheck failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
elif [ "${container_mode}" = "worker" ]; then
|
elif [ "${container_mode}" = "worker" ]; then
|
||||||
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
if [ "$(supervisorctl status worker:worker_0 | awk '{print tolower($2)}')" = "running" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
|
|
||||||
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
admin {$CADDY_SERVER_ADMIN_HOST}:{$CADDY_SERVER_ADMIN_PORT}
|
||||||
|
|
||||||
|
log {
|
||||||
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto_https off
|
||||||
|
|
||||||
|
skip_install_trust
|
||||||
|
|
||||||
frankenphp {
|
frankenphp {
|
||||||
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
worker "{$APP_PUBLIC_PATH}/frankenphp-worker.php" {$CADDY_SERVER_WORKER_COUNT}
|
||||||
}
|
}
|
||||||
@@ -20,7 +28,7 @@
|
|||||||
|
|
||||||
{$CADDY_SERVER_SERVER_NAME} {
|
{$CADDY_SERVER_SERVER_NAME} {
|
||||||
log {
|
log {
|
||||||
level WARN
|
level {$CADDY_SERVER_LOG_LEVEL:WARN}
|
||||||
|
|
||||||
format filter {
|
format filter {
|
||||||
wrap {$CADDY_SERVER_LOGGER}
|
wrap {$CADDY_SERVER_LOGGER}
|
||||||
@@ -60,7 +68,6 @@
|
|||||||
error @rejected 401
|
error @rejected 401
|
||||||
|
|
||||||
php_server {
|
php_server {
|
||||||
index frankenphp-worker.php
|
|
||||||
try_files {path} frankenphp-worker.php
|
try_files {path} frankenphp-worker.php
|
||||||
resolve_root_symlink
|
resolve_root_symlink
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,18 @@
|
|||||||
[program:octane]
|
[program:octane]
|
||||||
process_name = %(program_name)s_%(process_num)s
|
process_name = %(program_name)s_%(process_num)s
|
||||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-host=0.0.0.0 --admin-port=2019 --log-level=WARN --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
priority = 1
|
priority = 1
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopwaitsecs = 30
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
environment = LARAVEL_OCTANE = "1"
|
environment = LARAVEL_OCTANE = "1"
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
stderr_logfile_maxbytes = 0
|
stderr_logfile_maxbytes = 0
|
||||||
|
|
||||||
[program:horizon]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan horizon
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 3
|
|
||||||
autostart = %(ENV_WITH_HORIZON)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
stopwaitsecs = 3600
|
|
||||||
|
|
||||||
[program:scheduler]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = supercronic -overlapping /etc/supercronic/laravel
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
|
|
||||||
[program:clear-scheduler-cache]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
autostart = %(ENV_WITH_SCHEDULER)s
|
|
||||||
autorestart = false
|
|
||||||
startsecs = 0
|
|
||||||
startretries = 1
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
|
|
||||||
[program:reverb]
|
|
||||||
process_name = %(program_name)s_%(process_num)s
|
|
||||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
|
||||||
user = %(ENV_USER)s
|
|
||||||
priority = 2
|
|
||||||
autostart = %(ENV_WITH_REVERB)s
|
|
||||||
autorestart = true
|
|
||||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
|
||||||
stdout_logfile_maxbytes = 200MB
|
|
||||||
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
|
||||||
stderr_logfile_maxbytes = 200MB
|
|
||||||
minfds = 10000
|
|
||||||
|
|
||||||
[include]
|
[include]
|
||||||
files = /etc/supervisord.conf
|
files = /etc/supervisord.conf /etc/supervisor/conf.d/supervisord.services.conf
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
post_max_size = 100M
|
post_max_size = 100M
|
||||||
upload_max_filesize = 100M
|
upload_max_filesize = 100M
|
||||||
expose_php = 0
|
expose_php = 0
|
||||||
realpath_cache_size = 16M
|
realpath_cache_size = 32M
|
||||||
realpath_cache_ttl = 360
|
realpath_cache_ttl = 720
|
||||||
|
memory_limit = 256M
|
||||||
max_input_time = 5
|
max_input_time = 5
|
||||||
register_argc_argv = 0
|
register_argc_argv = 0
|
||||||
date.timezone = ${TZ:-UTC}
|
date.timezone = ${TZ:-UTC}
|
||||||
@@ -11,18 +12,24 @@ date.timezone = ${TZ:-UTC}
|
|||||||
[Opcache]
|
[Opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.enable_cli = 1
|
opcache.enable_cli = 1
|
||||||
opcache.memory_consumption = 256M
|
opcache.memory_consumption = 256
|
||||||
opcache.use_cwd = 0
|
opcache.use_cwd = 0
|
||||||
|
opcache.save_comments = 1
|
||||||
opcache.max_file_size = 0
|
opcache.max_file_size = 0
|
||||||
opcache.max_accelerated_files = 32531
|
opcache.max_accelerated_files = 32531
|
||||||
opcache.validate_timestamps = 0
|
opcache.validate_timestamps = 0
|
||||||
opcache.file_update_protection = 0
|
opcache.file_update_protection = 0
|
||||||
opcache.interned_strings_buffer = 16
|
opcache.interned_strings_buffer = 16
|
||||||
|
opcache.enable_file_override = 1
|
||||||
|
opcache.file_cache_consistency_checks = 0
|
||||||
|
opcache.file_cache = /tmp/opcache-file-cache
|
||||||
|
|
||||||
[JIT]
|
[JIT]
|
||||||
opcache.jit_buffer_size = 128M
|
opcache.jit_buffer_size = 128M
|
||||||
opcache.jit = function
|
opcache.jit = tracing
|
||||||
opcache.jit_prof_threshold = 0.001
|
opcache.jit_hot_loop = 16
|
||||||
|
opcache.jit_hot_func = 32
|
||||||
|
opcache.jit_hot_return = 4
|
||||||
opcache.jit_max_root_traces = 2048
|
opcache.jit_max_root_traces = 2048
|
||||||
opcache.jit_max_side_traces = 256
|
opcache.jit_max_side_traces = 256
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon = true
|
nodaemon = true
|
||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
logfile = /var/log/supervisor/supervisord.log
|
|
||||||
pidfile = /var/run/supervisord.pid
|
|
||||||
|
|
||||||
[supervisorctl]
|
[supervisorctl]
|
||||||
|
|
||||||
@@ -10,4 +8,4 @@ pidfile = /var/run/supervisord.pid
|
|||||||
port = 127.0.0.1:9001
|
port = 127.0.0.1:9001
|
||||||
|
|
||||||
[rpcinterface:supervisor]
|
[rpcinterface:supervisor]
|
||||||
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan horizon
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
15
docker/prod/deployment/supervisord.inertia.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[program:inertia-ssr-server]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = true
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = /dev/stdout
|
||||||
|
stdout_logfile_maxbytes = 0
|
||||||
|
stderr_logfile = /dev/stderr
|
||||||
|
stderr_logfile_maxbytes = 0
|
||||||
|
|
||||||
|
[include]
|
||||||
|
files = /etc/supervisord.conf
|
||||||
@@ -4,6 +4,8 @@ command = php %(ENV_ROOT)s/artisan reverb:start
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ command = supercronic -overlapping /etc/supercronic/laravel
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
69
docker/prod/deployment/supervisord.services.conf
Normal file
69
docker/prod/deployment/supervisord.services.conf
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[program:horizon]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan horizon
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 4
|
||||||
|
autostart = %(ENV_WITH_HORIZON)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/horizon.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
stopwaitsecs = 3600
|
||||||
|
|
||||||
|
[program:scheduler]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = supercronic -overlapping /etc/supercronic/laravel
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
|
||||||
|
[program:clear-scheduler-cache]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
autostart = %(ENV_WITH_SCHEDULER)s
|
||||||
|
autorestart = false
|
||||||
|
startsecs = 0
|
||||||
|
startretries = 1
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
|
||||||
|
[program:reverb]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 3
|
||||||
|
autostart = %(ENV_WITH_REVERB)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/reverb.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
|
minfds = 10000
|
||||||
|
|
||||||
|
[program:inertia-ssr-server]
|
||||||
|
process_name = %(program_name)s_%(process_num)s
|
||||||
|
command = php %(ENV_ROOT)s/artisan inertia:start-ssr --runtime=bun --quiet
|
||||||
|
user = %(ENV_USER)s
|
||||||
|
priority = 2
|
||||||
|
autostart = %(ENV_WITH_SSR)s
|
||||||
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
|
stdout_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||||
|
stdout_logfile_maxbytes = 200MB
|
||||||
|
stderr_logfile = %(ENV_ROOT)s/storage/logs/inertia-ssr-server.log
|
||||||
|
stderr_logfile_maxbytes = 200MB
|
||||||
@@ -4,6 +4,8 @@ command = %(ENV_WORKER_COMMAND)s
|
|||||||
user = %(ENV_USER)s
|
user = %(ENV_USER)s
|
||||||
autostart = true
|
autostart = true
|
||||||
autorestart = true
|
autorestart = true
|
||||||
|
stopasgroup = true
|
||||||
|
killasgroup = true
|
||||||
stdout_logfile = /dev/stdout
|
stdout_logfile = /dev/stdout
|
||||||
stdout_logfile_maxbytes = 0
|
stdout_logfile_maxbytes = 0
|
||||||
stderr_logfile = /dev/stderr
|
stderr_logfile = /dev/stderr
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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()!);
|
||||||
@@ -72,8 +71,6 @@ function saveWithChecks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
|
|
||||||
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
const billableRateSelect = ref<MemberBillableKey>('default-rate');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -162,12 +159,7 @@ const roleDescription = computed(() => {
|
|||||||
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
<FieldLabel :icon="BillableIcon" for="billableRateType"
|
||||||
>Billable Rate</FieldLabel
|
>Billable Rate</FieldLabel
|
||||||
>
|
>
|
||||||
<BillableRateInput
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
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 />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
|
||||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||||
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
|
||||||
@@ -14,8 +12,6 @@ const props = defineProps<{
|
|||||||
defineEmits<{
|
defineEmits<{
|
||||||
sort: [column: SortColumn];
|
sort: [column: SortColumn];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,10 +33,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
column="billable_rate"
|
column="billable_rate"
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
@sort="$emit('sort', $event)">
|
@sort="$emit('sort', $event)">
|
||||||
<span class="inline-flex items-center gap-2 whitespace-nowrap">
|
Billable Rate
|
||||||
Billable Rate
|
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
|
||||||
</span>
|
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
|
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
|
||||||
Status
|
Status
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<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';
|
||||||
@@ -26,7 +25,6 @@ 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 {
|
||||||
@@ -73,8 +71,6 @@ 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>
|
||||||
@@ -94,8 +90,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
{{ 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 py-4 text-sm text-text-primary">
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
<span v-if="member.billable_rate">
|
||||||
<span v-else-if="member.billable_rate">
|
|
||||||
{{
|
{{
|
||||||
formatCents(
|
formatCents(
|
||||||
member.billable_rate,
|
member.billable_rate,
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibility
|
|||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
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 { updateProject } = useProjectsStore();
|
const { updateProject } = useProjectsStore();
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
const { clients } = useClientsQuery();
|
const { clients } = useClientsQuery();
|
||||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||||
const show = defineModel('show', { default: false });
|
const show = defineModel('show', { default: false });
|
||||||
@@ -50,11 +48,6 @@ const project = ref<CreateProjectBody>({
|
|||||||
is_public: props.originalProject.is_public,
|
is_public: props.originalProject.is_public,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
|
|
||||||
function getUpdateBody() {
|
|
||||||
const { billable_rate: billableRate, ...body } = project.value;
|
|
||||||
return billableRatesLocked.value ? body : { ...body, billable_rate: billableRate };
|
|
||||||
}
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (props.originalProject.billable_rate !== project.value.billable_rate) {
|
if (props.originalProject.billable_rate !== project.value.billable_rate) {
|
||||||
// make sure that the alert modal is not immediately submitted when user presses enter
|
// make sure that the alert modal is not immediately submitted when user presses enter
|
||||||
@@ -63,7 +56,7 @@ async function submit() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await updateProject(props.originalProject.id, getUpdateBody());
|
await updateProject(props.originalProject.id, project.value);
|
||||||
show.value = false;
|
show.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +72,7 @@ const currentClientName = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function submitBillableRate() {
|
async function submitBillableRate() {
|
||||||
await updateProject(props.originalProject.id, getUpdateBody());
|
await updateProject(props.originalProject.id, project.value);
|
||||||
show.value = false;
|
show.value = false;
|
||||||
showBillableRateModal.value = false;
|
showBillableRateModal.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
|
||||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
|
||||||
@@ -27,8 +25,6 @@ const sortState = computed(() => ({
|
|||||||
function handleSort(column: SortColumn) {
|
function handleSort(column: SortColumn) {
|
||||||
emit('sort', column);
|
emit('sort', column);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -54,10 +50,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
column="billable_rate"
|
column="billable_rate"
|
||||||
v-bind="sortState"
|
v-bind="sortState"
|
||||||
@sort="handleSort">
|
@sort="handleSort">
|
||||||
<span class="inline-flex items-center gap-2 whitespace-nowrap">
|
Billable Rate
|
||||||
Billable Rate
|
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
|
||||||
</span>
|
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
|
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
|
||||||
Status
|
Status
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<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';
|
||||||
@@ -16,7 +15,6 @@ 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';
|
||||||
@@ -54,11 +52,8 @@ function deleteProject() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function archiveProject() {
|
function archiveProject() {
|
||||||
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
|
|
||||||
const { billable_rate: billableRate, ...project } = props.project;
|
|
||||||
useProjectsStore().updateProject(props.project.id, {
|
useProjectsStore().updateProject(props.project.id, {
|
||||||
...project,
|
...props.project,
|
||||||
...(billableRatesLocked.value ? {} : { billable_rate: billableRate }),
|
|
||||||
is_archived: !props.project.is_archived,
|
is_archived: !props.project.is_archived,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -83,8 +78,6 @@ const billableRateInfo = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showEditProjectModal = ref(false);
|
const showEditProjectModal = ref(false);
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -136,8 +129,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
<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 py-4 text-sm text-text-primary">
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
<span v-if="billableRateInfo">{{ billableRateInfo }}</span>
|
||||||
<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,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
|
||||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
|
||||||
import TableHeading from '@/Components/Common/TableHeading.vue';
|
import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -11,10 +7,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||||
Name
|
Name
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary flex items-center gap-2">
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
|
||||||
Billable Rate
|
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
|
||||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">Edit</span>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<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';
|
||||||
@@ -11,7 +10,6 @@ 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;
|
||||||
@@ -35,8 +33,6 @@ 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>
|
||||||
@@ -52,20 +48,17 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
</span>
|
</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">
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
{{
|
||||||
<span v-else>
|
projectMember.billable_rate
|
||||||
{{
|
? formatCents(
|
||||||
projectMember.billable_rate
|
projectMember.billable_rate,
|
||||||
? formatCents(
|
getOrganizationCurrencyString(),
|
||||||
projectMember.billable_rate,
|
organization?.currency_format,
|
||||||
getOrganizationCurrencyString(),
|
organization?.currency_symbol,
|
||||||
organization?.currency_format,
|
organization?.number_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 ?? '') }}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
|
||||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
|
||||||
import {
|
import {
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
@@ -292,8 +290,6 @@ const tableData = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -420,12 +416,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
|
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
|
||||||
Duration
|
Duration
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||||
v-if="showBillableRate"
|
|
||||||
class="flex items-center justify-end gap-2 pr-6">
|
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
|
||||||
Cost
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<template
|
<template
|
||||||
v-if="
|
v-if="
|
||||||
|
|||||||
@@ -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">
|
||||||
<slot>{{ value ?? '--' }}</slot>
|
{{ value ?? '--' }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.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';
|
||||||
@@ -103,8 +101,6 @@ 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: () => {
|
||||||
@@ -114,7 +110,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
|
enabled: computed(() => !!organizationId.value),
|
||||||
staleTime: 1000 * 30, // 30 seconds
|
staleTime: 1000 * 30, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,11 +270,7 @@ const option = computed(() => {
|
|||||||
)
|
)
|
||||||
: '--'
|
: '--'
|
||||||
" />
|
" />
|
||||||
<StatCard v-if="billableRatesLocked" title="Billable Amount">
|
|
||||||
<UpgradeLockedBadge />
|
|
||||||
</StatCard>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
v-else
|
|
||||||
title="Billable Amount"
|
title="Billable Amount"
|
||||||
:value="
|
:value="
|
||||||
totalWeeklyBillableAmount
|
totalWeeklyBillableAmount
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.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 {
|
||||||
@@ -128,8 +126,6 @@ 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>
|
||||||
@@ -155,10 +151,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
||||||
<div class="pl-6">Name</div>
|
<div class="pl-6">Name</div>
|
||||||
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div>
|
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div>
|
||||||
<div v-if="showBillableRate" class="flex items-center justify-end gap-2 pr-6">
|
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked" />
|
|
||||||
Cost
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { PanelLeft } 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 { computed, nextTick, onMounted, provide, ref } from 'vue';
|
import { 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';
|
||||||
@@ -37,9 +37,7 @@ import {
|
|||||||
canViewReport,
|
canViewReport,
|
||||||
canViewTags,
|
canViewTags,
|
||||||
} from '@/utils/permissions';
|
} from '@/utils/permissions';
|
||||||
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
|
||||||
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
|
|
||||||
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';
|
||||||
@@ -90,14 +88,6 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
|||||||
|
|
||||||
provide('organization', organization);
|
provide('organization', organization);
|
||||||
|
|
||||||
const showBillableRatesUpgradeModal = ref(false);
|
|
||||||
provide(billableRatesLockKey, {
|
|
||||||
locked: computed(() => !canUseBillableRates()),
|
|
||||||
requestUpgrade: () => {
|
|
||||||
showBillableRatesUpgradeModal.value = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
||||||
@@ -387,7 +377,4 @@ const page = usePage<{
|
|||||||
<NotificationContainer></NotificationContainer>
|
<NotificationContainer></NotificationContainer>
|
||||||
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
<UserTimezoneMismatchModal></UserTimezoneMismatchModal>
|
||||||
<CommandPaletteProvider></CommandPaletteProvider>
|
<CommandPaletteProvider></CommandPaletteProvider>
|
||||||
<UpgradeModal v-model:show="showBillableRatesUpgradeModal">
|
|
||||||
<strong>Billable Rates</strong> are only available in solidtime Professional.
|
|
||||||
</UpgradeModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
|
|
||||||
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
|
|
||||||
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';
|
||||||
@@ -82,8 +80,6 @@ const shownTasks = computed(() => {
|
|||||||
return task.project_id === projectId && task.is_done;
|
return task.project_id === projectId && task.is_done;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { locked: billableRatesLocked } = useBillableRatesLock();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -121,17 +117,11 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="px-4 space-x-1">
|
<div class="px-4 space-x-1">
|
||||||
<UpgradeLockedBadge v-if="billableRatesLocked"
|
<Badge v-if="project?.billable_rate">
|
||||||
>Billable Rates</UpgradeLockedBadge
|
|
||||||
>
|
|
||||||
<Badge v-else-if="project?.billable_rate">
|
|
||||||
{{ billableRateFormatted }}
|
{{ billableRateFormatted }}
|
||||||
/ h
|
/ h
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge v-if="project?.is_billable && !project?.billable_rate">
|
||||||
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>
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ 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: '',
|
||||||
@@ -77,8 +75,9 @@ function checkForConfirmationModal() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="organization"
|
v-if="organization"
|
||||||
id="organizationShowBillableRatesToEmployees"
|
id="organizationShowBillableRatesToEmployees"
|
||||||
v-model:checked="organizationBody.employees_can_see_billable_rates"
|
v-model:checked="
|
||||||
:disabled="billableRatesLocked"></Checkbox>
|
organizationBody.employees_can_see_billable_rates
|
||||||
|
"></Checkbox>
|
||||||
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
<FieldLabel for="organizationShowBillableRatesToEmployees"
|
||||||
>Show Billable Rates to Employees</FieldLabel
|
>Show Billable Rates to Employees</FieldLabel
|
||||||
>
|
>
|
||||||
@@ -86,10 +85,7 @@ function checkForConfirmationModal() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<PrimaryButton v-if="billableRatesLocked" @click="requestUpgrade"
|
<PrimaryButton @click="checkForConfirmationModal">Save</PrimaryButton>
|
||||||
>Upgrade</PrimaryButton
|
|
||||||
>
|
|
||||||
<PrimaryButton v-else @click="checkForConfirmationModal">Save</PrimaryButton>
|
|
||||||
</template>
|
</template>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ 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;
|
||||||
@@ -22,8 +20,6 @@ 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 });
|
||||||
|
|
||||||
@@ -34,9 +30,7 @@ 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)"
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
|
|
||||||
|
|
||||||
const { requestUpgrade } = useBillableRatesLock();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="billable_rates_locked"
|
|
||||||
class="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input-border bg-input-background px-3 text-left text-sm text-text-tertiary shadow-sm transition hover:border-border-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
@click.prevent.stop="requestUpgrade">
|
|
||||||
<span class="flex min-w-0 items-center gap-2">
|
|
||||||
<LockClosedIcon class="w-3.5 shrink-0 text-icon-default" />
|
|
||||||
<span class="truncate"><slot>Billable rates</slot></span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="shrink-0 rounded border border-border-secondary bg-secondary px-2 py-0.5 text-xs font-semibold text-text-secondary">
|
|
||||||
Upgrade
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -10,15 +10,12 @@ 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');
|
||||||
@@ -94,12 +91,7 @@ const emit = defineEmits(['submit']);
|
|||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
|
||||||
<BillableRateInput
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
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 />
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
|
||||||
import { useBillableRatesLock } from './utils/useBillableRatesLock';
|
|
||||||
|
|
||||||
const { requestUpgrade } = useBillableRatesLock();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="billable_rates_locked_badge"
|
|
||||||
class="inline-flex items-center space-x-1 rounded border border-border-secondary bg-secondary px-2 py-1 hover:border-border-tertiary hover:bg-tertiary"
|
|
||||||
@click.prevent.stop="requestUpgrade">
|
|
||||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
|
||||||
<span class="text-xs font-semibold text-text-secondary"><slot>Upgrade</slot></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -21,8 +21,6 @@ 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';
|
|
||||||
|
|
||||||
import Badge from './Badge.vue';
|
import Badge from './Badge.vue';
|
||||||
import Button from './Buttons/Button.vue';
|
import Button from './Buttons/Button.vue';
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
|
|
||||||
|
|
||||||
export interface BillableRatesLock {
|
|
||||||
locked: ComputedRef<boolean>;
|
|
||||||
requestUpgrade: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const billableRatesLockKey: InjectionKey<BillableRatesLock> = Symbol('billableRatesLock');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether billable rates are locked behind a plan upgrade for the current organization.
|
|
||||||
*
|
|
||||||
* The app layout provides the lock state and opens the upgrade dialog. Without a
|
|
||||||
* provider (e.g. public report views or other consumers of this package) billable
|
|
||||||
* rates count as unlocked.
|
|
||||||
*/
|
|
||||||
export function useBillableRatesLock(): BillableRatesLock {
|
|
||||||
return inject(billableRatesLockKey, {
|
|
||||||
locked: computed(() => false),
|
|
||||||
requestUpgrade: () => {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -70,13 +70,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ abstract class TestCase extends BaseTestCase
|
|||||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,18 +99,6 @@ abstract class TestCase extends BaseTestCase
|
|||||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
$mock->shouldReceive('getTrialUntil')->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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Api\V1\Controller;
|
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectMember;
|
|
||||||
use App\Models\TimeEntry;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Laravel\Passport\Passport;
|
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
|
||||||
|
|
||||||
#[UsesClass(Controller::class)]
|
|
||||||
class BillableRateRestrictionEndpointTest extends ApiEndpointTestAbstract
|
|
||||||
{
|
|
||||||
public function test_project_store_fails_with_billable_rate_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'projects:create',
|
|
||||||
]);
|
|
||||||
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->assertStatus(400);
|
|
||||||
$response->assertJsonPath('error', true);
|
|
||||||
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
|
|
||||||
$this->assertDatabaseMissing(Project::class, [
|
|
||||||
'name' => 'Project',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_project_store_without_billable_rate_works_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'projects:create',
|
|
||||||
]);
|
|
||||||
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' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$this->assertDatabaseHas(Project::class, [
|
|
||||||
'name' => 'Project',
|
|
||||||
'is_billable' => true,
|
|
||||||
'billable_rate' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_project_update_keeps_existing_billable_rate_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'projects:update',
|
|
||||||
]);
|
|
||||||
$project = Project::factory()->forOrganization($data->organization)->create([
|
|
||||||
'billable_rate' => 10000,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
|
||||||
'name' => 'RenamedKeep',
|
|
||||||
'color' => $project->color,
|
|
||||||
'client_id' => null,
|
|
||||||
'is_billable' => true,
|
|
||||||
'billable_rate' => 10000,
|
|
||||||
]);
|
|
||||||
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
|
||||||
'name' => 'RenamedChange',
|
|
||||||
'color' => $project->color,
|
|
||||||
'client_id' => null,
|
|
||||||
'is_billable' => true,
|
|
||||||
'billable_rate' => 20000,
|
|
||||||
]);
|
|
||||||
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
|
||||||
'name' => 'RenamedRemove',
|
|
||||||
'color' => $project->color,
|
|
||||||
'client_id' => null,
|
|
||||||
'is_billable' => true,
|
|
||||||
'billable_rate' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$responseKeep->assertStatus(200);
|
|
||||||
$responseKeep->assertJsonPath('data.name', 'RenamedKeep');
|
|
||||||
$responseChange->assertStatus(400);
|
|
||||||
$responseChange->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
|
|
||||||
$responseRemove->assertStatus(200);
|
|
||||||
$project->refresh();
|
|
||||||
$this->assertSame('RenamedRemove', $project->name);
|
|
||||||
$this->assertSame(10000, $project->billable_rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_project_update_without_billable_rate_does_not_change_project_rate_or_time_entries_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
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' => true,
|
|
||||||
'is_archived' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(200);
|
|
||||||
$project->refresh();
|
|
||||||
$this->assertSame('Renamed', $project->name);
|
|
||||||
$this->assertTrue($project->is_archived);
|
|
||||||
$this->assertSame(10000, $project->billable_rate);
|
|
||||||
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_organization_update_fails_to_set_billable_rate_or_employee_visibility_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'organizations:update',
|
|
||||||
]);
|
|
||||||
$data->organization->employees_can_see_billable_rates = false;
|
|
||||||
$data->organization->save();
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
|
||||||
'name' => 'Organization',
|
|
||||||
'billable_rate' => 10000,
|
|
||||||
]);
|
|
||||||
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
|
||||||
'name' => 'Organization',
|
|
||||||
'employees_can_see_billable_rates' => true,
|
|
||||||
]);
|
|
||||||
$responseUnchanged = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
|
|
||||||
'name' => 'Organization',
|
|
||||||
'billable_rate' => null,
|
|
||||||
'employees_can_see_billable_rates' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$responseRate->assertStatus(400);
|
|
||||||
$responseVisibility->assertStatus(400);
|
|
||||||
$responseUnchanged->assertStatus(200);
|
|
||||||
$data->organization->refresh();
|
|
||||||
$this->assertNull($data->organization->billable_rate);
|
|
||||||
$this->assertFalse($data->organization->employees_can_see_billable_rates);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_member_update_fails_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'members:update',
|
|
||||||
]);
|
|
||||||
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create([
|
|
||||||
'billable_rate' => null,
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
|
|
||||||
'billable_rate' => 10000,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertStatus(400);
|
|
||||||
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
|
|
||||||
$this->assertNull($member->refresh()->billable_rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_project_member_store_and_update_fail_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$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' => null,
|
|
||||||
]);
|
|
||||||
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' => 10000,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$responseStore->assertStatus(400);
|
|
||||||
$responseUpdate->assertStatus(400);
|
|
||||||
$this->assertNull($projectMember->refresh()->billable_rate);
|
|
||||||
$this->assertDatabaseMissing(ProjectMember::class, [
|
|
||||||
'member_id' => $otherMember->getKey(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
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_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$data = $this->createUserWithPermission([
|
|
||||||
'charts:view:own',
|
|
||||||
]);
|
|
||||||
Passport::actingAs($data->user);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$response->assertForbidden();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||||
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
$mock->shouldReceive('getTrialUntil')->andReturn(null);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
|
||||||
});
|
});
|
||||||
Passport::actingAs($user->user);
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
@@ -48,7 +47,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
->where('billing.has_trial', false)
|
->where('billing.has_trial', false)
|
||||||
->where('billing.trial_until', null)
|
->where('billing.trial_until', null)
|
||||||
->where('billing.is_blocked', false)
|
->where('billing.is_blocked', false)
|
||||||
->where('billing.can_use_billable_rates', true)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +61,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
$mock->shouldReceive('hasTrial')->andReturn(true);
|
$mock->shouldReceive('hasTrial')->andReturn(true);
|
||||||
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
|
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
|
||||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||||
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
|
|
||||||
});
|
});
|
||||||
Passport::actingAs($user->user);
|
Passport::actingAs($user->user);
|
||||||
|
|
||||||
@@ -76,7 +73,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
|||||||
->where('billing.has_trial', true)
|
->where('billing.has_trial', true)
|
||||||
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
|
->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
|
||||||
->where('billing.is_blocked', false)
|
->where('billing.is_blocked', false)
|
||||||
->where('billing.can_use_billable_rates', true)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ use App\Enums\TimeEntryType;
|
|||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
use App\Jobs\RecalculateSpentTimeForProject;
|
||||||
use App\Jobs\RecalculateSpentTimeForTask;
|
use App\Jobs\RecalculateSpentTimeForTask;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\ProjectMember;
|
|
||||||
use App\Models\TimeEntry;
|
use App\Models\TimeEntry;
|
||||||
use App\Service\Import\Importers\DefaultImporter;
|
use App\Service\Import\Importers\DefaultImporter;
|
||||||
use App\Service\Import\Importers\ImportException;
|
use App\Service\Import\Importers\ImportException;
|
||||||
@@ -104,34 +102,6 @@ class SolidtimeImporterTest extends ImporterTestAbstract
|
|||||||
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_import_drops_billable_rates_if_organization_can_not_use_billable_rates(): void
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
$this->actAsOrganizationWithoutBillableRates();
|
|
||||||
$zipPath = $this->createTestZip('solidtime_import_test_1');
|
|
||||||
$timezone = 'Europe/Vienna';
|
|
||||||
$organization = Organization::factory()->create();
|
|
||||||
$importer = new SolidtimeImporter;
|
|
||||||
$importer->init($organization);
|
|
||||||
$data = file_get_contents($zipPath);
|
|
||||||
Queue::fake([
|
|
||||||
RecalculateSpentTimeForProject::class,
|
|
||||||
RecalculateSpentTimeForTask::class,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
$importer->importData($data, $timezone);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
$this->assertSame(3, Project::query()->whereBelongsTo($organization, 'organization')->whereNull('billable_rate')->count());
|
|
||||||
$this->assertSame(1, ProjectMember::query()->whereBelongsToOrganization($organization)->whereNull('billable_rate')->count());
|
|
||||||
$timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get();
|
|
||||||
$this->assertCount(2, $timeEntries);
|
|
||||||
foreach ($timeEntries as $timeEntry) {
|
|
||||||
$this->assertNull($timeEntry->billable_rate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_import_of_test_file_with_type_column_imports_breaks(): void
|
public function test_import_of_test_file_with_type_column_imports_breaks(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user