Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
e793b81b35 add billable rate restriction interfaces for billing extension 2026-10-06 17:20:33 +02:00
99 changed files with 1099 additions and 1204 deletions

View File

@@ -141,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -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": "${{ secrets.LARAVEL_SPARK_USERNAME }}", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}' COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "gregor@vostrak.at", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
with: with:
working_dir: "extensions/Billing" working_dir: "extensions/Billing"
command: install command: install
@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -1,136 +0,0 @@
name: PHPUnit Tests - Extensions
on: push
permissions:
contents: read
jobs:
phpunit-extensions:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Activate billing extension"
run: php artisan module:enable Billing
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
- name: "Run PHPUnit"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.1.1 uses: codecov/codecov-action@v7.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Service\DashboardService; use App\Service\DashboardService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
@@ -159,7 +158,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->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->canSeeBillableRates($organization);
if (! $showBillableRate) { if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.'); throw new AuthorizationException('You do not have permission to view billable rates.');
} }

View File

@@ -4,6 +4,8 @@ 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;
@@ -49,4 +51,30 @@ 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;
}
}
} }

View File

@@ -83,6 +83,7 @@ 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);

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\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->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->canSeeBillableRates($organization);
return new OrganizationResource($organization, $showBillableRate); return new OrganizationResource($organization, $showBillableRate);
} }
@@ -54,7 +54,10 @@ class OrganizationController extends Controller
if ($request->getCurrency() !== null) { if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency(); $organization->currency = $request->getCurrency();
} }
if ($request->getEmployeesCanSeeBillableRates() !== null) { if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) {
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) {
@@ -83,6 +86,7 @@ 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();
} }

View File

@@ -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->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->canSeeBillableRates($organization);
return new ProjectCollection($projects, $showBillableRate); return new ProjectCollection($projects, $showBillableRate);
} }
@@ -99,6 +99,7 @@ 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');
@@ -125,6 +126,7 @@ 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');
@@ -139,14 +141,17 @@ class ProjectController extends Controller
} }
$oldBillableRate = $project->billable_rate; $oldBillableRate = $project->billable_rate;
$clientIdChanged = false; $clientIdChanged = false;
$project->billable_rate = $request->getBillableRate(); // 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
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 !== $request->getBillableRate()) { if ($oldBillableRate !== $project->billable_rate) {
$billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForProject($project);
} }
if ($clientIdChanged) { if ($clientIdChanged) {

View File

@@ -65,6 +65,7 @@ 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) {
@@ -98,6 +99,7 @@ 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();

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType; use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException;
@@ -240,7 +239,7 @@ class TimeEntryController extends Controller
} }
$user = $this->user(); $user = $this->user();
$timezone = $user->timezone; $timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->canSeeBillableRates($organization);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -386,7 +385,7 @@ class TimeEntryController extends Controller
} }
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->canSeeBillableRates($organization);
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
@@ -441,7 +440,7 @@ class TimeEntryController extends Controller
} }
$debug = $request->getDebug(); $debug = $request->getDebug();
$user = $this->user(); $user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->canSeeBillableRates($organization);
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();

View File

@@ -57,6 +57,7 @@ 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'),

View File

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

View File

@@ -44,7 +44,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => ($record['Billability'] ?? '') === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null, 'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
@@ -54,7 +54,6 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($tasksKey !== null && $record[$tasksKey] !== '') { if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]); $tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->checkTaskNameLength($task);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
'project_id' => $projectId, 'project_id' => $projectId,
@@ -84,6 +83,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'Project', 'Project',
'Status', 'Status',
'Visibility', 'Visibility',
'Billability',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {

View File

@@ -103,7 +103,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') { if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
$this->checkTaskNameLength($record[$taskKey]);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
@@ -228,6 +227,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Description', 'Description',
'User', 'User',
'Group',
'Email', 'Email',
'Tags', 'Tags',
'Start Date', 'Start Date',

View File

@@ -14,6 +14,7 @@ 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;
@@ -21,8 +22,6 @@ use Illuminate\Database\Eloquent\Builder;
abstract class DefaultImporter implements ImporterContract abstract class DefaultImporter implements ImporterContract
{ {
protected const TASK_NAME_MAX_LENGTH = 500;
protected Organization $organization; protected Organization $organization;
/** /**
@@ -73,9 +72,15 @@ 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);
@@ -98,7 +103,11 @@ abstract class DefaultImporter implements ImporterContract
'string', 'string',
'in:placeholder', 'in:placeholder',
], ],
]); ], beforeSave: function (Member $member): void {
if (! $this->canUseBillableRates) {
$member->billable_rate = null;
}
});
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) { $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id); return $builder->where('organization_id', $this->organization->id);
@@ -122,7 +131,7 @@ abstract class DefaultImporter implements ImporterContract
'uuid', 'uuid',
], ],
], beforeSave: function (Project $project): void { ], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) { if ($project->billable_rate === 0 || ! $this->canUseBillableRates) {
$project->billable_rate = null; $project->billable_rate = null;
} }
}); });
@@ -136,7 +145,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) { if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) {
$projectMember->billable_rate = null; $projectMember->billable_rate = null;
} }
}); });
@@ -183,16 +192,6 @@ abstract class DefaultImporter implements ImporterContract
$this->billableRateService = app(BillableRateService::class); $this->billableRateService = app(BillableRateService::class);
} }
/**
* @throws ImportException
*/
protected function checkTaskNameLength(string $taskName): void
{
if (strlen($taskName) > self::TASK_NAME_MAX_LENGTH) {
throw new ImportException('Task name ("'.$taskName.'") is too long, maximum length is '.self::TASK_NAME_MAX_LENGTH.' characters');
}
}
#[\Override] #[\Override]
public function getReport(): ReportDto public function getReport(): ReportDto
{ {

View File

@@ -113,7 +113,6 @@ class GenericTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['task'] !== '') { if ($record['task'] !== '') {
$this->checkTaskNameLength($record['task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['task'], 'name' => $record['task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -92,7 +92,6 @@ class HarvestTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -199,7 +199,6 @@ class SolidtimeImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task['name']);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task['name'], 'name' => $task['name'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -153,16 +153,9 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($projectMembers as $projectMember) { foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
if ($userId === null) {
throw new Exception('User does not exist');
}
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($userId);
if ($memberId === null) {
throw new Exception('Member does not exist');
}
$this->projectMemberImportHelper->getKey([ $this->projectMemberImportHelper->getKey([
'project_id' => $projectId, 'project_id' => $projectId,
'member_id' => $memberId, 'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
], [ ], [
'user_id' => $userId, 'user_id' => $userId,
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
@@ -189,7 +182,6 @@ class TogglDataImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task->name);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task->name, 'name' => $task->name,
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -97,7 +97,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -1,6 +1,7 @@
ARG PHP_VERSION=8.3 ARG PHP_VERSION=8.3
ARG FRANKENPHP_VERSION=1.11 ARG FRANKENPHP_VERSION=1.8
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/"
@@ -15,13 +16,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 \ xcaddy build v2.10.0 \
--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} FROM dunglas/frankenphp:${FRANKENPHP_VERSION}-php${PHP_VERSION} AS base
COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp COPY --from=upstream /usr/local/bin/frankenphp /usr/local/bin/frankenphp
@@ -31,28 +32,26 @@ 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 USER_ID=1000 ARG WWWUSER=1000
ARG GROUP_ID=1000 ARG WWWGROUP=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} \
LANG=C.UTF-8 \ USER=octane \
USER=laravel \ ROOT=${APP_DIR} \
ROOT=/var/www/html \ APP_ENV=${APP_ENV} \
APP_ENV=production \
COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_FUND=0 \ COMPOSER_FUND=0 \
COMPOSER_MAX_PARALLEL_HTTP=48 \ COMPOSER_MAX_PARALLEL_HTTP=24 \
WITH_HORIZON=false \ XDG_CONFIG_HOME=${APP_DIR}/.config \
WITH_SCHEDULER=false \ XDG_DATA_HOME=${APP_DIR}/.data \
WITH_REVERB=false \ SERVER_NAME=${APP_HOST}
WITH_SSR=false
ENV XDG_CONFIG_HOME=${ROOT}/.config XDG_DATA_HOME=${ROOT}/.data
WORKDIR ${ROOT} WORKDIR ${ROOT}
@@ -61,9 +60,6 @@ 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 \
@@ -72,36 +68,40 @@ 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 \
# && curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr bash \ libbrotli-dev \
# 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 \
ffi \ memcached \
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/* /var/log/lastlog /var/log/faillog && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
&& rm /var/log/lastlog /var/log/faillog
RUN arch="$(uname -m)" \ RUN arch="$(uname -m)" \
&& case "$arch" in \ && case "$arch" in \
@@ -111,64 +111,106 @@ 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.38/${_cronic_fname}" \ && wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.29/${_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 ${GROUP_ID} ${USER} \ && groupadd --force -g ${WWWGROUP} ${USER} \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g ${GROUP_ID} -u ${USER_ID} ${USER} && useradd -ms /bin/bash --no-log-init --no-user-group -g ${WWWGROUP} -u ${WWWUSER} ${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
COPY --link --from=vendor /usr/bin/composer /usr/bin/composer USER ${USER}
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 \ bootstrap/cache && chmod -R a+rw storage
&& chmod +x /usr/local/bin/start-container /usr/local/bin/healthcheck
RUN composer dump-autoload \ #RUN composer install \
--optimize \ # --classmap-authoritative \
--apcu \ # --no-interaction \
--no-dev # --no-ansi \
# --no-dev \
# && composer clear-cache
#RUN bun run build RUN cat .env
#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=30s --interval=10s --timeout=3s --retries=3 CMD healthcheck || exit 1 #HEALTHCHECK --start-period=5s --interval=2s --timeout=5s --retries=8 CMD healthcheck || exit 1

View File

@@ -22,13 +22,6 @@ 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

View File

@@ -3,14 +3,6 @@
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}
} }
@@ -28,7 +20,7 @@
{$CADDY_SERVER_SERVER_NAME} { {$CADDY_SERVER_SERVER_NAME} {
log { log {
level {$CADDY_SERVER_LOG_LEVEL:WARN} level WARN
format filter { format filter {
wrap {$CADDY_SERVER_LOGGER} wrap {$CADDY_SERVER_LOGGER}
@@ -68,6 +60,7 @@
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
} }

View File

@@ -1,18 +1,65 @@
[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-host=0.0.0.0 --admin-port=2019 --log-level=WARN --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-port=2019 --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 /etc/supervisor/conf.d/supervisord.services.conf files = /etc/supervisord.conf

View File

@@ -2,9 +2,8 @@
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 = 32M realpath_cache_size = 16M
realpath_cache_ttl = 720 realpath_cache_ttl = 360
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}
@@ -12,24 +11,18 @@ date.timezone = ${TZ:-UTC}
[Opcache] [Opcache]
opcache.enable = 1 opcache.enable = 1
opcache.enable_cli = 1 opcache.enable_cli = 1
opcache.memory_consumption = 256 opcache.memory_consumption = 256M
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 = tracing opcache.jit = function
opcache.jit_hot_loop = 16 opcache.jit_prof_threshold = 0.001
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

View File

@@ -1,6 +1,8 @@
[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]
@@ -8,4 +10,4 @@ user = %(ENV_USER)s
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

View File

@@ -4,8 +4,6 @@ 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

View File

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

View File

@@ -4,8 +4,6 @@ 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

View File

@@ -4,8 +4,6 @@ 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

View File

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

View File

@@ -4,8 +4,6 @@ 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

View File

@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
test('test that calendar navigation buttons work', async ({ page }) => { test('test that calendar navigation buttons work', async ({ page }) => {
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate between named periods. // Click the "next" button to navigate forward
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.fc')).toBeVisible();
// Click the "prev" button to navigate back
await page.getByRole('button', { name: 'Previous' }).click(); await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Last Week'); await expect(page.locator('.fc')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate forward first, then use the range button to return to today. // Navigate forward first, then click today
await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
await page.getByRole('button', { name: 'today' }).click(); await page.getByRole('button', { name: 'today' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week'); await expect(page.locator('.fc')).toBeVisible();
}); });
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {

View File

@@ -18,25 +18,6 @@ import {
// Each test registers a new user and creates test data via API // Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 }); test.describe.configure({ timeout: 30000 });
test('date range picker labels and navigates reporting periods', async ({ page }) => {
await goToReporting(page);
const range = page.getByTestId('date_range_picker_display');
const previous = page.getByTestId('date_range_picker_previous');
const next = page.getByTestId('date_range_picker_next');
await expect(range).toContainText('Last 14 Days');
await expect(next).toBeDisabled();
await Promise.all([waitForReportingUpdate(page), previous.click()]);
await expect(range).not.toContainText('Last 14 Days');
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
// does not necessarily trigger another aggregate request.
await next.click();
await expect(range).toContainText('Last 14 Days');
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Project Multiselect Dropdown Tests // Project Multiselect Dropdown Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1579,57 +1579,6 @@ test('test that project selection works in create modal', async ({ page, ctx })
expect(createBody.data.project_id).not.toBeNull(); expect(createBody.data.project_id).not.toBeNull();
}); });
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
page,
ctx,
}) => {
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The menu that opened the modal animates out and only then hands focus back to its
// own trigger.
await expect(page.locator('[role="menu"]')).toHaveCount(0);
// Fill the description, then move to the project dropdown and select a project purely via keyboard
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
await description.fill('Keyboard submit test');
await description.press('Tab');
await expect(
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
).toBeFocused();
await page.keyboard.press('Enter');
await page.getByTestId('client_dropdown_search').fill(projectName);
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
await page.keyboard.press('Enter');
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
await expect(projectTrigger).toBeVisible();
// The trigger label updates on the next tick, but the dropdown keeps focus until its
// exit animation has finished and reka-ui hands focus back to the trigger.
await expect(projectTrigger).toBeFocused();
// Ctrl+Enter must submit even though focus is no longer on the description input
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.keyboard.press('Control+Enter'),
]);
const createBody = await createResponse.json();
expect(createBody.data.description).toBe('Keyboard submit test');
expect(createBody.data.project_id).not.toBeNull();
await expect(page.getByRole('dialog')).toBeHidden();
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
'Keyboard submit test'
);
});
test('test that tag selection works in create modal', async ({ page }) => { test('test that tag selection works in create modal', async ({ page }) => {
await goToTimeOverview(page); await goToTimeOverview(page);

View File

@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
test('can navigate forward and return to current week', async ({ page }) => { test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]); await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This Week" // Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the // Go to next week — the text assertions below auto-retry until the
// header label flips. // header label flips.
await page.getByTestId('timesheet_next_week').click(); await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This Week" // Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week // Go back to this week
await page.getByTestId('timesheet_week_display').click(); await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "main" "ref": "v0.0.6"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",

View File

@@ -43,7 +43,5 @@
<env name="NEWSLETTER_URL" value="null"/> <env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
<env name="PADDLE_SANDBOX" value="true"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Client </span> <span> Create Client </span>

View File

@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Client </span> <span> Update Client </span>

View File

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

View File

@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Invite Member </span> <span> Invite Member </span>

View File

@@ -1,4 +1,6 @@
<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';
@@ -12,6 +14,8 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn];
}>(); }>();
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -33,7 +37,10 @@ defineEmits<{
column="billable_rate" column="billable_rate"
v-bind="props" v-bind="props"
@sort="$emit('sort', $event)"> @sort="$emit('sort', $event)">
Billable Rate <span class="inline-flex items-center gap-2 whitespace-nowrap">
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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { Member, Organization } from '@/packages/api/src'; import type { Member, Organization } from '@/packages/api/src';
import { api } from '@/packages/api/src'; import { api } from '@/packages/api/src';
import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline'; import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline';
@@ -25,6 +26,7 @@ import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue'; import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue';
import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue'; import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePlaceholderModal.vue';
import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue'; import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { capitalizeFirstLetter } from '../../../utils/format'; import { capitalizeFirstLetter } from '../../../utils/format';
import { formatCents } from '../../../packages/ui/src/utils/money'; import { formatCents } from '../../../packages/ui/src/utils/money';
import { import {
@@ -71,6 +73,8 @@ async function invitePlaceholder(id: string) {
const userHasValidMailAddress = computed(() => { const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test'); return !props.member.email.endsWith('@solidtime-import.test');
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -90,7 +94,8 @@ const userHasValidMailAddress = computed(() => {
{{ 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">
<span v-if="member.billable_rate"> <UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="member.billable_rate">
{{ {{
formatCents( formatCents(
member.billable_rate, member.billable_rate,

View File

@@ -23,8 +23,10 @@ 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 });
@@ -48,6 +50,11 @@ 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
@@ -56,7 +63,7 @@ async function submit() {
}, 0); }, 0);
return; return;
} }
await updateProject(props.originalProject.id, project.value); await updateProject(props.originalProject.id, getUpdateBody());
show.value = false; show.value = false;
} }
@@ -72,14 +79,14 @@ const currentClientName = computed(() => {
}); });
async function submitBillableRate() { async function submitBillableRate() {
await updateProject(props.originalProject.id, project.value); await updateProject(props.originalProject.id, getUpdateBody());
show.value = false; show.value = false;
showBillableRateModal.value = false; showBillableRateModal.value = false;
} }
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit Project {{ props.originalProject.name }} </span> <span> Edit Project {{ props.originalProject.name }} </span>

View File

@@ -1,4 +1,6 @@
<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';
@@ -25,6 +27,8 @@ 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>
@@ -50,7 +54,10 @@ function handleSort(column: SortColumn) {
column="billable_rate" column="billable_rate"
v-bind="sortState" v-bind="sortState"
@sort="handleSort"> @sort="handleSort">
Billable Rate <span class="inline-flex items-center gap-2 whitespace-nowrap">
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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue'; import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -15,6 +16,7 @@ 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';
@@ -52,8 +54,11 @@ 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, {
...props.project, ...project,
...(billableRatesLocked.value ? {} : { billable_rate: billableRate }),
is_archived: !props.project.is_archived, is_archived: !props.project.is_archived,
}); });
} }
@@ -78,6 +83,8 @@ const billableRateInfo = computed(() => {
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -129,7 +136,8 @@ const showEditProjectModal = ref(false);
<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">
<span v-if="billableRateInfo">{{ billableRateInfo }}</span> <UpgradeLockedBadge v-if="billableRatesLocked" />
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span> <span v-else class="text-text-tertiary">--</span>
</div> </div>
<div <div

View File

@@ -38,7 +38,7 @@ useFocus(projectNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span>Add Project Member</span> <span>Add Project Member</span>

View File

@@ -63,7 +63,7 @@ useFocus(projectNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span>Edit Project Member</span> <span>Edit Project Member</span>

View File

@@ -1,5 +1,9 @@
<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>
@@ -7,7 +11,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<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">Billable Rate</div> <div class="px-3 py-1.5 text-left text-text-tertiary flex items-center gap-2">
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>

View File

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

View File

@@ -79,7 +79,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Report </span> <span> Create Report </span>

View File

@@ -96,7 +96,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Report </span> <span> Create Report </span>

View File

@@ -1,4 +1,6 @@
<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,
@@ -57,7 +59,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = useSessionStorage<string>( const endDate = useSessionStorage<string>(
'reporting-end-date', 'reporting-end-date',
@@ -290,6 +292,8 @@ const tableData = computed(() => {
}; };
}); });
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -416,7 +420,12 @@ const tableData = computed(() => {
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''"> <div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
Duration Duration
</div> </div>
<div v-if="showBillableRate" class="text-right pr-6">Cost</div> <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="

View File

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

View File

@@ -36,7 +36,7 @@ useFocus(tagNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Tag </span> <span> Update Tag </span>

View File

@@ -50,7 +50,7 @@ useFocus(taskNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Task </span> <span> Create Task </span>

View File

@@ -35,7 +35,7 @@ useFocus(taskNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Task </span> <span> Update Task </span>

View File

@@ -1,4 +1,6 @@
<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';
@@ -101,6 +103,8 @@ const { data: totalWeeklyBillableTime } = useQuery({
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
const { data: totalWeeklyBillableAmount } = useQuery({ const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ['totalWeeklyBillableAmount', organizationId], queryKey: ['totalWeeklyBillableAmount', organizationId],
queryFn: () => { queryFn: () => {
@@ -110,7 +114,7 @@ const { data: totalWeeklyBillableAmount } = useQuery({
}, },
}); });
}, },
enabled: computed(() => !!organizationId.value), enabled: computed(() => !!organizationId.value && !billableRatesLocked.value),
staleTime: 1000 * 30, // 30 seconds staleTime: 1000 * 30, // 30 seconds
}); });
@@ -270,7 +274,11 @@ const option = computed(() => {
) )
: '--' : '--'
" /> " />
<StatCard v-if="billableRatesLocked" title="Billable Amount">
<UpgradeLockedBadge />
</StatCard>
<StatCard <StatCard
v-else
title="Billable Amount" title="Billable Amount"
:value=" :value="
totalWeeklyBillableAmount totalWeeklyBillableAmount

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import 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 {
@@ -126,6 +128,8 @@ const showBillableRate = computed(() => {
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
); );
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -151,7 +155,10 @@ const showBillableRate = computed(() => {
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="text-right pr-6">Cost</div> <div v-if="showBillableRate" class="flex items-center justify-end gap-2 pr-6">
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
</div> </div>
<div <div

View File

@@ -179,7 +179,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="emit('cancel')" @submit="submit"> <DialogModal closeable :show="show" @close="emit('cancel')">
<template #title> <template #title>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Coffee class="w-5 h-5 text-text-secondary" /> <Coffee class="w-5 h-5 text-text-secondary" />

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue'; import { Button } from '@/packages/ui/src/Buttons';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{ defineProps<{
isCurrentWeek: boolean;
weekNumber: number; weekNumber: number;
rangeLabel: string; weekRangeDisplay: string;
weekTotalFormatted: string; weekTotalFormatted: string;
}>(); }>();
@@ -17,16 +19,33 @@ defineEmits<{
<template> <template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
<!-- Left: Week navigation --> <!-- Left: Week navigation -->
<DateRangeNavigator <div class="flex items-center gap-2">
:label="rangeLabel" <Button
:secondary-label="`W${weekNumber}`" variant="outline"
previous-test-id="timesheet_prev_week" size="icon"
trigger-test-id="timesheet_week_display" class="h-8 w-8"
next-test-id="timesheet_next_week" data-testid="timesheet_prev_week"
trigger-aria-label="Return to current week" @click="$emit('previous')">
@previous="$emit('previous')" <ChevronLeftIcon class="h-4 w-4" />
@next="$emit('next')" </Button>
@select="$emit('current')" /> <button
data-testid="timesheet_week_display"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
@click="$emit('current')">
<CalendarIcon class="h-4 w-4 text-icon-default" />
<span v-if="isCurrentWeek">This week</span>
<span v-else>{{ weekRangeDisplay }}</span>
<span class="text-text-tertiary">&middot; W{{ weekNumber }}</span>
</button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_next_week"
@click="$emit('next')">
<ChevronRightIcon class="h-4 w-4" />
</Button>
</div>
<!-- Right: Week total --> <!-- Right: Week total -->
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">

View File

@@ -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 { nextTick, onMounted, provide, ref } from 'vue'; import { computed, nextTick, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue'; import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init'; import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -37,7 +37,9 @@ import {
canViewReport, canViewReport,
canViewTags, canViewTags,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing'; import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import UpgradeModal from '@/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';
@@ -88,6 +90,14 @@ 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
@@ -377,4 +387,7 @@ 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>

View File

@@ -1,4 +1,6 @@
<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';
@@ -80,6 +82,8 @@ 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>
@@ -117,11 +121,17 @@ const shownTasks = computed(() => {
</li> </li>
</ol> </ol>
<div class="px-4 space-x-1"> <div class="px-4 space-x-1">
<Badge v-if="project?.billable_rate"> <UpgradeLockedBadge v-if="billableRatesLocked"
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
{{ billableRateFormatted }} {{ billableRateFormatted }}
/ h / h
</Badge> </Badge>
<Badge v-if="project?.is_billable && !project?.billable_rate"> <Badge
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
Default Rate Default Rate
</Badge> </Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge> <Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = useSessionStorage<string>( const endDate = useSessionStorage<string>(
'reporting-end-date', 'reporting-end-date',

View File

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

View File

@@ -30,7 +30,6 @@ import {
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement'; import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek'; import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations'; import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations'; import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
@@ -45,6 +44,7 @@ const {
weekEnd, weekEnd,
weekDays, weekDays,
weekNumber, weekNumber,
isCurrentWeek,
todayDate, todayDate,
goToPreviousWeek, goToPreviousWeek,
goToNextWeek, goToNextWeek,
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value) formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
); );
const weekRangeLabel = computed(() => const weekRangeDisplay = computed(() => {
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day')) const start = weekStart.value;
); const end = start.add(6, 'day');
return start.month() === end.month()
? `${start.format('MMM D')} - ${end.format('D')}`
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
});
// ── Cell / row mutation handlers ────────────────────────────────── // ── Cell / row mutation handlers ──────────────────────────────────
const { const {
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
<AppLayout title="Timesheet" data-testid="timesheet_view"> <AppLayout title="Timesheet" data-testid="timesheet_view">
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6"> <div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimesheetHeader <TimesheetHeader
:is-current-week="isCurrentWeek"
:week-number="weekNumber" :week-number="weekNumber"
:range-label="weekRangeLabel" :week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted" :week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek" @previous="goToPreviousWeek"
@next="goToNextWeek" @next="goToNextWeek"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Modal from './Modal.vue'; import Modal from './Modal.vue';
const emit = defineEmits(['close', 'submit']); const emit = defineEmits(['close']);
defineProps({ defineProps({
show: { show: {
@@ -24,12 +24,7 @@ const close = () => {
</script> </script>
<template> <template>
<Modal <Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
@submit="emit('submit')">
<div class="px-4 lg:px-6 py-4"> <div class="px-4 lg:px-6 py-4">
<div class="text-lg font-medium text-text-primary" role="heading"> <div class="text-lg font-medium text-text-primary" role="heading">
<slot name="title" /> <slot name="title" />

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { Tabs, TabsList } from '../tabs'; import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue'; import TabBarItem from '../TabBar/TabBarItem.vue';
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue'; import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
defineProps<{ defineProps<{
rangeLabel: string; viewTitle: string;
activeView: string; activeView: string;
settings: CalendarSettings; settings: CalendarSettings;
}>(); }>();
@@ -22,13 +23,31 @@ const emit = defineEmits<{
<template> <template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5"> <div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<DateRangeNavigator <!-- Left: Navigation -->
:label="rangeLabel" <div class="flex items-center gap-1">
trigger-test-id="calendar-title" <Button
trigger-aria-label="today" variant="outline"
@previous="emit('prev')" size="sm"
@next="emit('next')" class="h-8 w-8 p-0"
@select="emit('today')" /> aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings --> <!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">

View File

@@ -138,7 +138,7 @@ const {
const { const {
activeView, activeView,
viewDays, viewDays,
rangeLabel, viewTitle,
emitDatesChange, emitDatesChange,
handlePrev, handlePrev,
handleNext, handleNext,
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<template v-if="!loading"> <template v-if="!loading">
<CalendarToolbar <CalendarToolbar
:range-label="rangeLabel" :view-title="viewTitle"
:active-view="activeView" :active-view="activeView"
:settings="calendarSettings" :settings="calendarSettings"
@prev="handlePrev" @prev="handlePrev"

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time'; import { getLocalizedDayJs } from '../utils/time';
import { getWeekStartDayNumber } from '../utils/settings'; import { getWeekStartDayNumber } from '../utils/settings';
import { getDateRangeLabel } from '../utils/dateRange';
export function useCalendarNavigation(callbacks: { export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void; onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
return days; return days;
}); });
const rangeLabel = computed(() => { const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const days = viewDays.value; const days = viewDays.value;
if (days.length === 0) return ''; if (days.length === 0) return '';
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
const first = days[0]!;
const last = days[days.length - 1]!;
if (first.year() !== last.year()) {
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
}
if (first.month() !== last.month()) {
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
}
return first.format('MMMM YYYY');
}); });
function emitDatesChange() { function emitDatesChange() {
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
activeView, activeView,
currentDate, currentDate,
viewDays, viewDays,
rangeLabel, viewTitle,
emitDatesChange, emitDatesChange,
handlePrev, handlePrev,
handleNext, handleNext,

View File

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

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
import { Button } from '../Buttons';
withDefaults(
defineProps<{
label: string;
secondaryLabel?: string;
previousDisabled?: boolean;
nextDisabled?: boolean;
previousTestId?: string;
triggerTestId?: string;
nextTestId?: string;
triggerAriaLabel?: string;
}>(),
{
secondaryLabel: undefined,
previousTestId: undefined,
triggerTestId: undefined,
nextTestId: undefined,
triggerAriaLabel: undefined,
}
);
const emit = defineEmits<{
previous: [];
next: [];
select: [];
}>();
const triggerClass =
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
</script>
<template>
<div
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
role="group"
aria-label="Date range navigation">
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Previous"
:disabled="previousDisabled"
:data-testid="previousTestId"
@click="emit('previous')">
<ChevronLeft class="h-4 w-4" />
</Button>
<slot name="trigger" :trigger-class="triggerClass">
<Button
type="button"
variant="ghost"
size="sm"
:class="triggerClass"
:aria-label="triggerAriaLabel"
:data-testid="triggerTestId"
@click="emit('select')">
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
<span class="min-w-0 truncate">{{ label }}</span>
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
&middot; {{ secondaryLabel }}
</span>
</Button>
</slot>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Next"
:disabled="nextDisabled"
:data-testid="nextTestId"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View File

@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from '@lucide/vue'; import { CalendarIcon } from '@lucide/vue';
import { computed, ref, inject, type ComputedRef, watch } from 'vue'; import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import DateRangeNavigator from './DateRangeNavigator.vue'; import {
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time'; getDayJsInstance,
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange'; getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src'; import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings'; import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay); const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const props = withDefaults( const props = defineProps<{
defineProps<{ start: string;
start: string; end: string;
end: string; }>();
allowFuture?: boolean;
}>(),
{ allowFuture: false }
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:start', value: string): void; (e: 'update:start', value: string): void;
@@ -35,13 +35,10 @@ interface CalendarDateRange {
} }
const today = computed(() => { const today = computed(() => {
const now = getLocalizedDayJs(); const now = getDayJsInstance()();
return new CalendarDate(now.year(), now.month() + 1, now.date()); return new CalendarDate(now.year(), now.month() + 1, now.date());
}); });
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
const modelValue = computed<CalendarDateRange>({ const modelValue = computed<CalendarDateRange>({
get: () => ({ get: () => ({
start: props.start start: props.start
@@ -94,7 +91,7 @@ function setLastWeek() {
} }
function setLast14Days() { function setLast14Days() {
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format()); emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format());
open.value = false; open.value = false;
} }
@@ -112,14 +109,14 @@ function setLastMonth() {
} }
function setLast30Days() { function setLast30Days() {
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format()); emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format());
open.value = false; open.value = false;
} }
function setLast90Days() { function setLast90Days() {
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format()); emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getDayJsInstance()().format());
open.value = false; open.value = false;
} }
@@ -143,38 +140,6 @@ function setLastYear() {
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
const displayLabel = computed(() => {
if (!startDay.value) return 'Pick a date';
if (!endDay.value) {
return getDateRangeLabel(startDay.value, startDay.value, {
dateFormat: organization?.value?.date_format,
});
}
return getDateRangeLabel(startDay.value, endDay.value, {
dateFormat: organization?.value?.date_format,
});
});
const nextRange = computed(() => {
if (!startDay.value || !endDay.value) return undefined;
return shiftDateRange(startDay.value, endDay.value, 1);
});
const nextDisabled = computed(
() =>
!props.allowFuture &&
!!nextRange.value &&
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
);
function navigate(direction: DateRangeDirection) {
if (!startDay.value || !endDay.value) return;
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
emit('update:start', shifted.start.format());
emit('update:end', shifted.end.format());
emit('submit');
}
watch(open, (value) => { watch(open, (value) => {
if (value === false) { if (value === false) {
emit('submit'); emit('submit');
@@ -183,120 +148,77 @@ watch(open, (value) => {
</script> </script>
<template> <template>
<DateRangeNavigator <Popover v-model:open="open">
:label="displayLabel" <PopoverTrigger as-child>
:next-disabled="nextDisabled" <Button
previous-test-id="date_range_picker_previous" variant="outline"
trigger-test-id="date_range_picker_display" :class="
next-test-id="date_range_picker_next" twMerge(
@previous="navigate(-1)" 'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
@next="navigate(1)"> !modelValue && 'text-muted-foreground'
<template #trigger="{ triggerClass }"> )
<Popover v-model:open="open"> ">
<PopoverTrigger as-child> <CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<Button <template v-if="modelValue.start">
type="button" <template v-if="modelValue.end">
variant="ghost" {{ formatDate(modelValue.start.toString(), organization?.date_format) }}
size="sm" -
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')" {{ formatDate(modelValue.end.toString(), organization?.date_format) }}
data-testid="date_range_picker_display"> </template>
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" /> <template v-else>
<span class="min-w-0 truncate">{{ displayLabel }}</span> {{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</Button> </template>
</PopoverTrigger> </template>
<PopoverContent class="w-auto p-0"> <template v-else> Pick a date </template>
<div class="flex divide-x divide-border-secondary"> </Button>
<div </PopoverTrigger>
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2"> <PopoverContent class="w-auto p-0">
<Button <div class="flex divide-x divide-border-secondary">
variant="ghost" <div
size="sm" class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
class="justify-start" <Button variant="ghost" size="sm" class="justify-start" @click="setToday"
@click="setToday" >Today</Button
>Today</Button >
> <Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
<Button >This Week</Button
variant="ghost" >
size="sm" <Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
class="justify-start" >Last Week</Button
@click="setThisWeek" >
>This Week</Button <Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
> >Last 14 days</Button
<Button >
variant="ghost" <Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
size="sm" >This Month</Button
class="justify-start" >
@click="setLastWeek" <Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Week</Button >Last Month</Button
> >
<Button <Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
variant="ghost" >Last 30 days</Button
size="sm" >
class="justify-start" <Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
@click="setLast14Days" >Last 90 days</Button
>Last 14 Days</Button >
> <Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
<Button >Last 12 months</Button
variant="ghost" >
size="sm" <Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
class="justify-start" >This year</Button
@click="setThisMonth" >
>This Month</Button <Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
> >Last year</Button
<Button >
variant="ghost" </div>
size="sm" <div class="pl-2">
class="justify-start" <RangeCalendar
@click="setLastMonth" v-model="modelValue"
>Last Month</Button initial-focus
> :number-of-months="2"
<Button :max-value="today"
variant="ghost" :week-starts-on="weekStartsOn" />
size="sm" </div>
class="justify-start" </div>
@click="setLast30Days" </PopoverContent>
>Last 30 Days</Button </Popover>
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast90Days"
>Last 90 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast12Months"
>Last 12 Months</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisYear"
>This Year</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastYear"
>Last Year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="allowFuture ? undefined : today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>
</DateRangeNavigator>
</template> </template>

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Dialog, DialogContent, DialogFooter } from './dialog/index'; import { Dialog, DialogContent, DialogFooter } from './dialog/index';
import { computed, nextTick } from 'vue'; import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -17,7 +17,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['close', 'submit']); const emit = defineEmits(['close']);
const close = () => { const close = () => {
if (props.closeable) { if (props.closeable) {
@@ -25,25 +25,6 @@ const close = () => {
} }
}; };
// Ctrl+Enter (Cmd+Enter on macOS) submits the modal from any focused element inside it.
// Handled in the capture phase so child elements (buttons, dropdown triggers, inputs with
// their own Enter handlers) never see the keystroke and cannot open or double-submit.
async function onKeydownCapture(event: KeyboardEvent) {
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) {
return;
}
event.preventDefault();
event.stopPropagation();
// Inputs like the time and duration fields commit their value on blur, so blur first
// and let the resulting model updates settle before submitting.
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
await nextTick();
emit('submit');
}
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
return { return {
sm: 'sm:max-w-sm', sm: 'sm:max-w-sm',
@@ -58,7 +39,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog :open="show" @update:open="close"> <Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass"> <DialogContent :class="maxWidthClass">
<div class="min-w-0" @keydown.capture="onKeydownCapture"> <div class="min-w-0">
<slot /> <slot />
</div> </div>

View File

@@ -72,7 +72,7 @@ const currentClientName = computed(() => {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Project </span> <span> Create Project </span>

View File

@@ -10,12 +10,15 @@ 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');
@@ -91,7 +94,12 @@ const emit = defineEmits(['submit']);
</Field> </Field>
<Field> <Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel> <FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> <BillableRateInput
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="currency"
name="billableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType"> <Select v-model="rateType">
<SelectTrigger id="billableRateType"> <SelectTrigger id="billableRateType">
<SelectValue /> <SelectValue />

View File

@@ -30,7 +30,7 @@ useFocus(tagNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Tags </span> <span> Create Tags </span>

View File

@@ -78,7 +78,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400"> <div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5" /> <Coffee class="w-5 h-5" />

View File

@@ -126,7 +126,7 @@ const billableProxy = computed({
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create manual time entry </span> <span> Create manual time entry </span>

View File

@@ -164,7 +164,7 @@ const typeProxy = computed({
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit time entry </span> <span> Edit time entry </span>

View File

@@ -16,7 +16,6 @@ import {
} from '@/packages/api/src'; } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src'; import { Checkbox } from '@/packages/ui/src';
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid'; import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -61,13 +60,6 @@ const projectId = ref<string | null>(null);
const billable = ref<boolean | undefined>(undefined); const billable = ref<boolean | undefined>(undefined);
const selectedTags = ref<string[]>([]); const selectedTags = ref<string[]>([]);
// Clearing the project puts both fields back to "leave unchanged", the same state the form
// returns to after a successful submit.
function resetProject() {
projectId.value = null;
taskId.value = undefined;
}
const timeEntryBillable = computed({ const timeEntryBillable = computed({
get: () => { get: () => {
if (billable.value === undefined) { if (billable.value === undefined) {
@@ -155,7 +147,7 @@ const showBreakWarning = computed(
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update {{ timeEntries.length }} time entries </span> <span> Update {{ timeEntries.length }} time entries </span>
@@ -190,59 +182,42 @@ const showBreakWarning = computed(
</Field> </Field>
<Field> <Field>
<FieldLabel for="project">Project</FieldLabel> <FieldLabel for="project">Project</FieldLabel>
<div class="flex items-center gap-1 min-w-0"> <TimeTrackerProjectTaskDropdown
<!-- the dropdown declares its own `class` prop, which goes to the v-model:project="projectId"
trigger button, so the growing has to happen on a wrapper --> v-model:task="taskId"
<div class="flex-1 min-w-0"> variant="input"
<TimeTrackerProjectTaskDropdown align="start"
v-model:project="projectId" size="default"
v-model:task="taskId" :clients
variant="input" :create-project
align="start" :create-client
size="default" :currency="currency"
:clients :organization-billable-rate="organizationBillableRate"
:create-project :can-create-project
:create-client empty-placeholder="Select project..."
:currency="currency" allow-reset
:organization-billable-rate="organizationBillableRate" :enable-estimated-time
:can-create-project :projects="projects"
empty-placeholder="Select project..." :tasks="tasks"></TimeTrackerProjectTaskDropdown>
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
</div>
<button
v-if="projectId !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</Field> </Field>
<Field> <Field>
<FieldLabel>Tag</FieldLabel> <FieldLabel>Tag</FieldLabel>
<div class="flex items-center space-x-5"> <div class="flex space-x-5">
<!-- the dropdown root is `min-w-0`, so as a flex item it would shrink <TagDropdown
below its trigger and let the button overflow into the checkbox --> v-model="selectedTags"
<div class="shrink-0"> :create-tag
<TagDropdown :tags="tags"
v-model="selectedTags" :show-no-tag-option="false">
:create-tag <template #trigger>
:tags="tags" <Button variant="input" :disabled="removeAllTags">
:show-no-tag-option="false"> <TagIcon class="h-4 text-icon-default" />
<template #trigger> <span v-if="selectedTags.length > 0">
<Button variant="input" :disabled="removeAllTags"> Set {{ selectedTags.length }} tags
<TagIcon class="h-4 text-icon-default" /> </span>
<span v-if="selectedTags.length > 0"> <span v-else>Select Tags...</span>
Set {{ selectedTags.length }} tags </Button>
</span> </template>
<span v-else>Select Tags...</span> </TagDropdown>
</Button>
</template>
</TagDropdown>
</div>
<Field orientation="horizontal"> <Field orientation="horizontal">
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox> <Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
<FieldLabel for="no_tags">Remove all tags</FieldLabel> <FieldLabel for="no_tags">Remove all tags</FieldLabel>

View File

@@ -12,7 +12,7 @@ import type {
Client, Client,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { PlusCircleIcon, MinusIcon } from '@heroicons/vue/16/solid'; import { PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue'; import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
@@ -58,6 +58,7 @@ const props = withDefaults(
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string; currency: string;
emptyPlaceholder?: string; emptyPlaceholder?: string;
allowReset?: boolean;
noProjectValue?: string | null; noProjectValue?: string | null;
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
organizationBillableRate: number | null; organizationBillableRate: number | null;
@@ -69,6 +70,7 @@ const props = withDefaults(
}>(), }>(),
{ {
emptyPlaceholder: 'No Project', emptyPlaceholder: 'No Project',
allowReset: false,
noProjectValue: NO_PROJECT_ID, noProjectValue: NO_PROJECT_ID,
variant: 'ghost', variant: 'ghost',
align: 'center', align: 'center',
@@ -556,6 +558,12 @@ function selectProject(projectId: string) {
emit('changed', project.value, task.value); emit('changed', project.value, task.value);
} }
function resetProject() {
project.value = null;
task.value = null;
emit('changed', project.value, task.value);
}
const showCreateProject = ref(false); const showCreateProject = ref(false);
</script> </script>
@@ -563,28 +571,40 @@ const showCreateProject = ref(false);
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align"> <Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger> <template #trigger>
<slot name="trigger"> <slot name="trigger">
<Button <div class="flex items-center gap-1">
:variant="props.variant" <Button
:size="props.size" :variant="props.variant"
:class="twMerge('w-full justify-start overflow-hidden', props.class)"> :size="props.size"
<div :class="twMerge('w-full justify-start overflow-hidden', props.class)">
class="w-2.5 h-2.5 rounded-full shrink-0" <div
:style="{ backgroundColor: selectedProjectColor }"></div> class="w-2.5 h-2.5 rounded-full shrink-0"
<span class="truncate shrink-[1] text-text-primary">{{ :style="{ backgroundColor: selectedProjectColor }"></div>
selectedProjectName <span class="truncate shrink-[1] text-text-primary">{{
}}</span> selectedProjectName
<template v-if="currentTask"> }}</span>
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" /> <template v-if="currentTask">
<span class="truncate shrink-[100]">{{ currentTask.name }}</span> <ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
</template> <span class="truncate shrink-[100]">{{ currentTask.name }}</span>
<template v-if="selectedClientName"> </template>
<span class="dark:text-text-tertiary text-text-quaternary shrink-0">•</span> <template v-if="selectedClientName">
<span <span class="dark:text-text-tertiary text-text-quaternary shrink-0"
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary" >•</span
>{{ selectedClientName }}</span >
> <span
</template> class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
</Button> >{{ selectedClientName }}</span
>
</template>
</Button>
<button
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</slot> </slot>
</template> </template>
<template #content> <template #content>

View File

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

View File

@@ -21,6 +21,8 @@ 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';
@@ -132,7 +134,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue'; import DateRangePicker from './Input/DateRangePicker.vue';
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
import { Label } from './label/index'; import { Label } from './label/index';
import { import {
NumberField, NumberField,
@@ -212,7 +213,6 @@ export {
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuTrigger, ContextMenuTrigger,
DateRangePicker, DateRangePicker,
DateRangeNavigator,
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,

View File

@@ -1,124 +0,0 @@
import dayjs from 'dayjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { getDayJsInstance, getLocalizedDayJs } from './time';
import { getDateRangeLabel, shiftDateRange } from './dateRange';
const reference = dayjs('2026-09-21');
afterEach(() => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
});
describe('getDateRangeLabel', () => {
test('labels relative weeks', () => {
getDayJsInstance();
expect(
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
referenceDate: reference,
})
).toBe('This Week');
expect(
getDateRangeLabel(
reference.subtract(1, 'week').startOf('week'),
reference.subtract(1, 'week').endOf('week'),
{ referenceDate: reference }
)
).toBe('Last Week');
});
test('labels common rolling ranges', () => {
expect(
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
referenceDate: reference,
})
).toBe('Last 14 Days');
});
test('labels full calendar months and years', () => {
expect(
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
referenceDate: reference,
})
).toBe('September 2025');
expect(
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
referenceDate: reference,
})
).toBe('2024');
});
test('keeps relative labels for the current periods', () => {
expect(
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
referenceDate: reference,
})
).toBe('This Month');
expect(
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
referenceDate: reference,
})
).toBe('This Year');
});
test('uses the organization date format for custom ranges', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
dateFormat: 'slash-separated-dd-mm-yyyy',
referenceDate: reference,
})
).toBe('03/08/2026 – 12/08/2026');
});
test('renders a single historical day without duplicating it', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
referenceDate: reference,
})
).toBe('Aug 3, 2026');
});
});
describe('shiftDateRange', () => {
test('moves full weeks as calendar periods', () => {
getDayJsInstance();
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
});
test('moves full months without truncating longer months', () => {
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
});
test('moves custom ranges by their inclusive duration', () => {
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
});
test('normalizes custom range offsets across the spring DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-03-14T23:00:00Z'),
getLocalizedDayJs('2026-03-27T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
});
test('normalizes custom range offsets across the autumn DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-10-17T22:00:00Z'),
getLocalizedDayJs('2026-10-30T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
});
});

View File

@@ -1,135 +0,0 @@
import type { Dayjs } from 'dayjs';
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
export type DateRangeDirection = -1 | 1;
function isSameDay(left: Dayjs, right: Dayjs): boolean {
return left.isSame(right, 'day');
}
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
}
function formatCompactRange(start: Dayjs, end: Dayjs): string {
if (start.year() !== end.year()) {
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
}
if (start.month() !== end.month()) {
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
}
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
}
/**
* Returns a human-friendly label for an inclusive date range. Relative labels
* are intentionally shared by reporting, the timesheet, and the calendar.
*/
export function getDateRangeLabel(
start: Dayjs,
end: Dayjs,
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
): string {
// Ensure the configured first day of the week is applied before using startOf('week').
getDayJsInstance();
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
const rangeStart = start.startOf('day');
const rangeEnd = end.startOf('day');
const relativeDays: Array<[number, string]> = [
[0, 'Today'],
[-1, 'Yesterday'],
[1, 'Tomorrow'],
];
for (const [offset, label] of relativeDays) {
const day = reference.add(offset, 'day');
if (isRange(rangeStart, rangeEnd, day, day)) return label;
}
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
['week', 0, 'This Week'],
['week', -1, 'Last Week'],
['week', 1, 'Next Week'],
['month', 0, 'This Month'],
['month', -1, 'Last Month'],
['month', 1, 'Next Month'],
['year', 0, 'This Year'],
['year', -1, 'Last Year'],
['year', 1, 'Next Year'],
];
for (const [unit, offset, label] of relativePeriods) {
const period = reference.add(offset, unit);
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
}
// Named calendar periods outside the relative windows above, e.g. "May 2025"
// for a full month and "2025" for a full year.
const monthStart = rangeStart.startOf('month');
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
return rangeStart.format('MMMM YYYY');
}
const yearStart = rangeStart.startOf('year');
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
return rangeStart.format('YYYY');
}
const rollingRanges: Array<[number, string]> = [
[14, 'Last 14 Days'],
[30, 'Last 30 Days'],
[90, 'Last 90 Days'],
];
for (const [days, label] of rollingRanges) {
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
return label;
}
}
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
return 'Last 12 Months';
}
if (isSameDay(rangeStart, rangeEnd)) {
return options.dateFormat
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
: rangeStart.format('MMM D, YYYY');
}
if (options.dateFormat) {
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
rangeEnd.format('YYYY-MM-DD'),
options.dateFormat
)}`;
}
return formatCompactRange(rangeStart, rangeEnd);
}
/** Shift an inclusive range by one period, preserving full calendar periods. */
export function shiftDateRange(
start: Dayjs,
end: Dayjs,
direction: DateRangeDirection
): { start: Dayjs; end: Dayjs } {
getDayJsInstance();
const rangeStart = start.startOf('day');
const rangeEnd = end.endOf('day');
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
return { start: shiftedStart, end: shiftedStart.endOf('year') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
return { start: shiftedStart, end: shiftedStart.endOf('month') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
return { start: shiftedStart, end: shiftedStart.endOf('week') };
}
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
return {
start: rangeStart.add(days * direction, 'day').startOf('day'),
end: rangeEnd.add(days * direction, 'day').endOf('day'),
};
}

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
"Project","Client","Status","Visibility","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Project members","Project manager","Note"
"Project for Big Company","Big Company","Active","Public","Task 1, Task 2, Task 3","1.38","","","","","Constantin Graf","",""
"Project without Client","","Active","Public","","0.00","","","","","Constantin Graf","",""
"TEST","Big Company","Active","Public","","0.00","","","","","Constantin Graf","",""
1 Project Client Status Visibility Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Progress(%) Project members Project manager Note
2 Project for Big Company Big Company Active Public Task 1, Task 2, Task 3 1.38 Constantin Graf
3 Project without Client Active Public 0.00 Constantin Graf
4 TEST Big Company Active Public 0.00 Constantin Graf

View File

@@ -89,6 +89,7 @@ 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);
}); });
} }
@@ -99,6 +100,18 @@ 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);
}); });
} }
} }

View File

@@ -0,0 +1,286 @@
<?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();
}
}

View File

@@ -35,6 +35,7 @@ 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);
@@ -47,6 +48,7 @@ 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)
); );
} }
@@ -61,6 +63,7 @@ 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);
@@ -73,6 +76,7 @@ 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)
); );
} }
} }

View File

@@ -119,24 +119,6 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
); );
} }
public function test_import_of_test_file_without_billability_column_defaults_to_not_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports don't contain a "Billability" column.
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project for Big Company')->firstOrFail();
$this->assertFalse($project->is_billable);
}
public function test_import_supports_activities_column_alias_for_tasks(): void public function test_import_supports_activities_column_alias_for_tasks(): void
{ {
// Arrange // Arrange

View File

@@ -66,27 +66,4 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated); $this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_fails_if_task_name_is_too_long(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$taskName = str_repeat('a', 501);
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","'.$taskName.'","Peter Tester","peter.test@email.test"';
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('Task name ("'.$taskName.'") is too long, maximum length is 500 characters', $e->getMessage());
return;
}
$this->fail();
}
} }

View File

@@ -8,6 +8,8 @@ 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;
@@ -102,6 +104,34 @@ 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