Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
eb7233dfba anchor cross-day calendar e2e tests to Wednesday and Thursday
The tests used today and tomorrow (or yesterday) and skipped when those fell outside the visible week. The Saturday skips were also wrong for the Monday week start e2e users get, so the tests failed whenever CI ran on a Sunday. Using Wednesday and Thursday of the current week keeps both days visible whichever day the suite runs, so the day-of-week skips are gone.
2026-09-28 17:18:40 +02:00
Gregor Vostrak
398a81798b move project task count into its own sortable column
Also removes the ring around the project color dot, shrinks it slightly and tightens its spacing to the name.
2026-09-28 17:18:40 +02:00
38 changed files with 211 additions and 882 deletions

View File

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

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillingContract; use App\Service\BillingContract;
use App\Service\PermissionStore; use App\Service\PermissionStore;
@@ -51,30 +49,4 @@ class Controller extends \App\Http\Controllers\Controller
{ {
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization); return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
} }
protected function canUseBillableRates(Organization $organization): bool
{
return app(BillingContract::class)->canUseBillableRates($organization);
}
protected function canSeeBillableRates(Organization $organization): bool
{
if (! $this->canUseBillableRates($organization)) {
return false;
}
return $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
}
/**
* Organizations that can not use billable rates may keep or remove an existing rate, but not set a new one
*
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
protected function checkBillableRateChange(Organization $organization, ?int $oldBillableRate, ?int $newBillableRate): void
{
if ($newBillableRate !== null && $newBillableRate !== $oldBillableRate && ! $this->canUseBillableRates($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
}
} }

View File

@@ -83,7 +83,6 @@ class MemberController extends Controller
$this->checkPermission($organization, 'members:update', $member); $this->checkPermission($organization, 'members:update', $member);
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) { if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
$this->checkBillableRateChange($organization, $member->billable_rate, $request->getBillableRate());
$member->billable_rate = $request->getBillableRate(); $member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member); $billableRateService->updateTimeEntriesBillableRateForMember($member);

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->canSeeBillableRates($organization); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new OrganizationResource($organization, $showBillableRate); return new OrganizationResource($organization, $showBillableRate);
} }
@@ -54,10 +54,7 @@ class OrganizationController extends Controller
if ($request->getCurrency() !== null) { if ($request->getCurrency() !== null) {
$organization->currency = $request->getCurrency(); $organization->currency = $request->getCurrency();
} }
if ($request->getEmployeesCanSeeBillableRates() !== null && $request->getEmployeesCanSeeBillableRates() !== $organization->employees_can_see_billable_rates) { if ($request->getEmployeesCanSeeBillableRates() !== null) {
if (! $this->canUseBillableRates($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates(); $organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
} }
if ($request->getEmployeesCanManageTasks() !== null) { if ($request->getEmployeesCanManageTasks() !== null) {
@@ -86,7 +83,6 @@ class OrganizationController extends Controller
} }
$hasBillableRate = $request->has('billable_rate'); $hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) { if ($hasBillableRate) {
$this->checkBillableRateChange($organization, $organization->billable_rate, $request->getBillableRate());
$oldBillableRate = $organization->billable_rate; $oldBillableRate = $organization->billable_rate;
$organization->billable_rate = $request->getBillableRate(); $organization->billable_rate = $request->getBillableRate();
} }

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->canSeeBillableRates($organization); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new ProjectCollection($projects, $showBillableRate); return new ProjectCollection($projects, $showBillableRate);
} }
@@ -99,7 +99,6 @@ class ProjectController extends Controller
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'projects:create'); $this->checkPermission($organization, 'projects:create');
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
$project = new Project; $project = new Project;
$project->name = $request->input('name'); $project->name = $request->input('name');
$project->color = $request->input('color'); $project->color = $request->input('color');
@@ -126,7 +125,6 @@ class ProjectController extends Controller
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{ {
$this->checkPermission($organization, 'projects:update', $project); $this->checkPermission($organization, 'projects:update', $project);
$this->checkBillableRateChange($organization, $project->billable_rate, $request->getBillableRate());
$project->name = $request->input('name'); $project->name = $request->input('name');
$project->color = $request->input('color'); $project->color = $request->input('color');
$project->is_billable = (bool) $request->input('is_billable'); $project->is_billable = (bool) $request->input('is_billable');
@@ -141,17 +139,14 @@ class ProjectController extends Controller
} }
$oldBillableRate = $project->billable_rate; $oldBillableRate = $project->billable_rate;
$clientIdChanged = false; $clientIdChanged = false;
// Organizations that can not use billable rates do not receive the rate of the project, so the incoming value is ignored to keep the existing rate $project->billable_rate = $request->getBillableRate();
if ($this->canUseBillableRates($organization)) {
$project->billable_rate = $request->getBillableRate();
}
if ($project->client_id !== $request->input('client_id')) { if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id'); $project->client_id = $request->input('client_id');
$clientIdChanged = true; $clientIdChanged = true;
} }
$project->save(); $project->save();
if ($oldBillableRate !== $project->billable_rate) { if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForProject($project);
} }
if ($clientIdChanged) { if ($clientIdChanged) {

View File

@@ -65,7 +65,6 @@ class ProjectMemberController extends Controller
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
{ {
$this->checkPermission($organization, 'project-members:create', $project); $this->checkPermission($organization, 'project-members:create', $project);
$this->checkBillableRateChange($organization, null, $request->getBillableRate());
$member = Member::findOrFail((string) $request->input('member_id')); $member = Member::findOrFail((string) $request->input('member_id'));
if ($member->user->is_placeholder) { if ($member->user->is_placeholder) {
@@ -99,7 +98,6 @@ class ProjectMemberController extends Controller
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{ {
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$this->checkBillableRateChange($organization, $projectMember->billable_rate, $request->getBillableRate());
$oldBillableRate = $projectMember->billable_rate; $oldBillableRate = $projectMember->billable_rate;
$projectMember->billable_rate = $request->getBillableRate(); $projectMember->billable_rate = $request->getBillableRate();
$projectMember->save(); $projectMember->save();

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\BillingContract;
use App\Service\ColorService; use App\Service\ColorService;
use App\Service\Import\ImportDatabaseHelper; use App\Service\Import\ImportDatabaseHelper;
use App\Service\TimezoneService; use App\Service\TimezoneService;
@@ -72,15 +71,9 @@ abstract class DefaultImporter implements ImporterContract
protected BillableRateService $billableRateService; protected BillableRateService $billableRateService;
/**
* Organizations that can not use billable rates import projects, members and project members without billable rates
*/
protected bool $canUseBillableRates;
public function init(Organization $organization): void public function init(Organization $organization): void
{ {
$this->organization = $organization; $this->organization = $organization;
$this->canUseBillableRates = app(BillingContract::class)->canUseBillableRates($organization);
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization); return $builder->belongsToOrganization($this->organization);
@@ -103,11 +96,7 @@ abstract class DefaultImporter implements ImporterContract
'string', 'string',
'in:placeholder', 'in:placeholder',
], ],
], beforeSave: function (Member $member): void { ]);
if (! $this->canUseBillableRates) {
$member->billable_rate = null;
}
});
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) { $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'client_id', 'organization_id'], true, function (Builder $builder) {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->where('organization_id', $this->organization->id); return $builder->where('organization_id', $this->organization->id);
@@ -131,7 +120,7 @@ abstract class DefaultImporter implements ImporterContract
'uuid', 'uuid',
], ],
], beforeSave: function (Project $project): void { ], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0 || ! $this->canUseBillableRates) { if ($project->billable_rate === 0) {
$project->billable_rate = null; $project->billable_rate = null;
} }
}); });
@@ -145,7 +134,7 @@ abstract class DefaultImporter implements ImporterContract
'max:2147483647', 'max:2147483647',
], ],
], beforeSave: function (ProjectMember $projectMember): void { ], beforeSave: function (ProjectMember $projectMember): void {
if ($projectMember->billable_rate === 0 || ! $this->canUseBillableRates) { if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null; $projectMember->billable_rate = null;
} }
}); });

View File

@@ -53,6 +53,47 @@ function todayAt(hour: number, minute: number = 0): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
} }
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
// whichever day the suite runs on.
function wednesdayAt(hour: number, minute: number = 0): Date {
const now = new Date();
const daysSinceMonday = (now.getDay() + 6) % 7;
return new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - daysSinceMonday + 2,
hour,
minute,
0,
0
);
}
function thursdayAt(hour: number, minute: number = 0): Date {
const d = wednesdayAt(hour, minute);
d.setDate(d.getDate() + 1);
return d;
}
function toApiTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
function toDateStr(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${month}-${day}`;
}
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
await expect(header).toBeVisible();
const box = await header.boundingBox();
return box!.x + box!.width / 2;
}
/** /**
* These tests verify that changing the project on a time entry via the calendar * These tests verify that changing the project on a time entry via the calendar
* updates the billable status to match the new project's is_billable setting. * updates the billable status to match the new project's is_billable setting.
@@ -962,14 +1003,9 @@ test.describe('Drag-to-Move Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
const dayOfWeek = now.getDay(); const start = toApiTimestamp(thursdayAt(0, 30));
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start) const end = toApiTimestamp(thursdayAt(1, 30));
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
const start = todayAt(0, 30);
const end = todayAt(1, 30);
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Drag up past midnight test', description: 'Drag up past midnight test',
start, start,
@@ -978,11 +1014,10 @@ test.describe('Drag-to-Move Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const thursdayCol = page.locator(
.toISOString() `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
.split('T')[0]; );
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`); const event = thursdayCol
const event = todayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Drag up past midnight test' }); .filter({ hasText: 'Drag up past midnight test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });
@@ -1018,10 +1053,7 @@ test.describe('Drag-to-Move Events', () => {
expect(newDurationMs).toBe(3600000); expect(newDurationMs).toBe(3600000);
// The event should have moved to the previous day // The event should have moved to the previous day
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
}); });
}); });
@@ -1261,8 +1293,8 @@ test.describe('Resize Events', () => {
}); });
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => { test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const end = todayAt(11); const end = toApiTimestamp(wednesdayAt(11));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test', description: 'Cross resize end test',
start, start,
@@ -1271,26 +1303,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a column AFTER today (for end resize, end must be > start) // Resize into Thursday's column (for end resize, end must be > start)
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
// Pick first column after today, or skip if today is last
for (let i = todayIndex + 1; i < headerCount; i++) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column after today to resize to');
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1318,7 +1332,7 @@ test.describe('Resize Events', () => {
// First drag down vertically to engage resize (like test 3.1) // First drag down vertically to engage resize (like test 3.1)
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 }); await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
// Then move horizontally to a later day column // Then move horizontally to a later day column
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 }); await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1340,8 +1354,8 @@ test.describe('Resize Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const end = todayAt(14); const end = toApiTimestamp(wednesdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test', description: 'Cross resize end test',
start, start,
@@ -1350,30 +1364,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a non-today column header that is AFTER today (later day needed for end-edge resize) // Resize into Thursday's column (later day needed for end-edge resize)
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, thursdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let foundToday = false;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
foundToday = true;
continue;
}
if (foundToday) {
const box = await header.boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
}
// If today is the last column, use the one before today instead won't work for end resize,
// so skip this test in that edge case
if (targetX === undefined) {
test.skip();
return;
}
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1398,7 +1390,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Move to a different day column at same Y position // Move to a different day column at same Y position
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 }); await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1464,22 +1456,9 @@ test.describe('Resize Events', () => {
}); });
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => { test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
// Create entry spanning today evening → tomorrow morning // Create entry spanning Wednesday evening → Thursday morning
const start = todayAt(20); const start = toApiTimestamp(wednesdayAt(20));
const tomorrow = new Date(); const end = toApiTimestamp(thursdayAt(10));
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
10,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi-day end resize', description: 'Multi-day end resize',
@@ -1488,14 +1467,14 @@ test.describe('Resize Events', () => {
}); });
await goToCalendar(page); await goToCalendar(page);
// Check if tomorrow column is visible const thursdayCol = page.locator(
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view'); );
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find the event segment on tomorrow's column // Find the event segment on Thursday's column
const event = tomorrowCol const event = thursdayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Multi-day end resize' }) .filter({ hasText: 'Multi-day end resize' })
.first(); .first();
@@ -1541,22 +1520,9 @@ test.describe('Resize Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
// Create entry spanning today → tomorrow // Create entry spanning Wednesday → Thursday
const start = todayAt(10); const start = toApiTimestamp(wednesdayAt(10));
const tomorrow = new Date(); const end = toApiTimestamp(thursdayAt(14));
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
const end = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0,
0
)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Backward end resize multi', description: 'Backward end resize multi',
@@ -1565,29 +1531,16 @@ test.describe('Resize Events', () => {
}); });
await goToCalendar(page); await goToCalendar(page);
// Check if tomorrow column is visible const thursdayCol = page.locator(
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); `.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view'); );
await scrollCalendarToTime(page, '12:00:00'); await scrollCalendarToTime(page, '12:00:00');
// Find today's column header to get its X center const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
const headers = page.locator('.fc-col-header-cell');
const headerCount = await headers.count();
let todayX: number | undefined;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
const box = await header.boundingBox();
todayX = box!.x + box!.width / 2;
break;
}
}
test.skip(todayX === undefined, 'Could not find today column header');
// Find event segment on tomorrow's column and resize end backward to today // Find event segment on Thursday's column and resize end backward to Wednesday
const event = tomorrowCol const event = thursdayCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Backward end resize multi' }) .filter({ hasText: 'Backward end resize multi' })
.first(); .first();
@@ -1609,9 +1562,9 @@ test.describe('Resize Events', () => {
await page.mouse.move(centerX, bottomY - 3); await page.mouse.move(centerX, bottomY - 3);
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Drag down a bit first, then move to today's column at a Y after the start // Drag down a bit first, then move to Wednesday's column at a Y after the start
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 }); await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 }); await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
await page.mouse.up(); await page.mouse.up();
})(), })(),
]); ]);
@@ -1627,8 +1580,8 @@ test.describe('Resize Events', () => {
}); });
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => { test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
const start = todayAt(10); const start = toApiTimestamp(thursdayAt(10));
const end = todayAt(14); const end = toApiTimestamp(thursdayAt(14));
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'End before start test', description: 'End before start test',
start, start,
@@ -1637,25 +1590,8 @@ test.describe('Resize Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00'); await scrollCalendarToTime(page, '09:00:00');
// Find a column BEFORE today // Target Wednesday's column, the day before the entry
const headers = page.locator('.fc-col-header-cell'); const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
const headerCount = await headers.count();
let targetX: number | undefined;
let todayIndex = -1;
for (let i = 0; i < headerCount; i++) {
const header = headers.nth(i);
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
if (isToday) {
todayIndex = i;
break;
}
}
for (let i = todayIndex - 1; i >= 0; i--) {
const box = await headers.nth(i).boundingBox();
targetX = box!.x + box!.width / 2;
break;
}
test.skip(targetX === undefined, 'No column before today to test');
const event = page const event = page
.locator('.fc-event') .locator('.fc-event')
@@ -1683,7 +1619,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.down(); await page.mouse.down();
// Move to earlier column at a Y position near the top of the grid (before start time) // Move to earlier column at a Y position near the top of the grid (before start time)
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 }); await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
await page.mouse.up(); await page.mouse.up();
// Wait for any potential API call // Wait for any potential API call
@@ -1732,30 +1668,21 @@ test.describe('Click-Drag Selection to Create', () => {
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({ test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
page, page,
}) => { }) => {
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday with Monday week start)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
// Use mid-day times so both start and end slots are visible in the viewport // Use mid-day times so both start and end slots are visible in the viewport
await scrollCalendarToTime(page, '10:00:00'); await scrollCalendarToTime(page, '10:00:00');
// Find today's and tomorrow's columns // Find Wednesday's and Thursday's columns
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const wednesdayStr = toDateStr(wednesdayAt(0));
.toISOString() const thursdayStr = toDateStr(thursdayAt(0));
.split('T')[0];
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`); const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`); const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
await expect(todayCol).toBeVisible(); await expect(wednesdayCol).toBeVisible();
await expect(tomorrowCol).toBeVisible(); await expect(thursdayCol).toBeVisible();
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow) // Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first(); const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
await expect(startSlot).toBeVisible(); await expect(startSlot).toBeVisible();
@@ -1763,18 +1690,18 @@ test.describe('Click-Drag Selection to Create', () => {
const startSlotBox = await startSlot.boundingBox(); const startSlotBox = await startSlot.boundingBox();
const endSlotBox = await endSlot.boundingBox(); const endSlotBox = await endSlot.boundingBox();
const todayColBox = await todayCol.boundingBox(); const wednesdayColBox = await wednesdayCol.boundingBox();
const tomorrowColBox = await tomorrowCol.boundingBox(); const thursdayColBox = await thursdayCol.boundingBox();
// Start drag at 11:00 on today's column // Start drag at 11:00 on Wednesday's column
const startX = todayColBox!.x + todayColBox!.width / 2; const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
const startY = startSlotBox!.y + 2; const startY = startSlotBox!.y + 2;
// End drag at 13:00 on tomorrow's column // End drag at 13:00 on Thursday's column
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2; const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
const endY = endSlotBox!.y + 2; const endY = endSlotBox!.y + 2;
// Drag from today to tomorrow — move down first, then across // Drag from Wednesday to Thursday, moving down first, then across
const slotHeight = await getSlotHeight(page); const slotHeight = await getSlotHeight(page);
await page.mouse.move(startX, startY); await page.mouse.move(startX, startY);
await page.mouse.down(); await page.mouse.down();
@@ -1792,9 +1719,9 @@ test.describe('Click-Drag Selection to Create', () => {
await expect(dialog.getByText('Start')).toBeVisible(); await expect(dialog.getByText('Start')).toBeVisible();
await expect(dialog.getByText('End')).toBeVisible(); await expect(dialog.getByText('End')).toBeVisible();
// Start date should be today, end date should be tomorrow // Start date should be Wednesday, end date should be Thursday
await expect(dialog.getByText(todayStr)).toBeVisible(); await expect(dialog.getByText(wednesdayStr)).toBeVisible();
await expect(dialog.getByText(tomorrowStr)).toBeVisible(); await expect(dialog.getByText(thursdayStr)).toBeVisible();
}); });
}); });
@@ -1876,17 +1803,9 @@ test.describe('Timezone & Localization', () => {
test.describe('Multi-Day Events', () => { test.describe('Multi-Day Events', () => {
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => { test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
// Create entry that spans from today 22:00 to tomorrow 02:00 // Create entry that spans from Wednesday 22:00 to Thursday 02:00
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
const dayOfWeek = now.getDay(); const end = toApiTimestamp(thursdayAt(2));
// If today is Saturday (6), the entry would span to next week and may not be visible
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day entry', description: 'Multi day entry',
@@ -1901,14 +1820,8 @@ test.describe('Multi-Day Events', () => {
}); });
test('multi-day event can be edited via click', async ({ page, ctx }) => { test('multi-day event can be edited via click', async ({ page, ctx }) => {
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
test.skip(now.getDay() === 6, 'Skip on Saturday'); const end = toApiTimestamp(thursdayAt(2));
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day edit test', description: 'Multi day edit test',
@@ -1927,14 +1840,8 @@ test.describe('Multi-Day Events', () => {
}); });
test('multi-day event context menu works', async ({ page, ctx }) => { test('multi-day event context menu works', async ({ page, ctx }) => {
const now = new Date(); const start = toApiTimestamp(wednesdayAt(22));
test.skip(now.getDay() === 6, 'Skip on Saturday'); const end = toApiTimestamp(thursdayAt(2));
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day ctx test', description: 'Multi day ctx test',
@@ -1954,16 +1861,11 @@ test.describe('Multi-Day Events', () => {
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const dayOfWeek = now.getDay(); const startDate = wednesdayAt(22);
// Need today and tomorrow both visible (skip Saturday) const endDate = thursdayAt(2);
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week'); const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag test', description: 'Multi day drag test',
@@ -1973,12 +1875,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00) // Find the clipped segment on Thursday's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
.toISOString() const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });
const eventBox = await event.boundingBox(); const eventBox = await event.boundingBox();
@@ -2013,27 +1912,19 @@ test.describe('Multi-Day Events', () => {
// Duration must be preserved (4 hours) // Duration must be preserved (4 hours)
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000); expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
// The start should still be on today (not jumped to tomorrow) // The start should still be on Wednesday (not jumped to Thursday)
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) expect(toDateStr(newStart)).toBe(toDateStr(startDate));
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
}); });
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({ test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
page, page,
ctx, ctx,
}) => { }) => {
const now = new Date(); // Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const dayOfWeek = now.getDay(); const startDate = wednesdayAt(22);
// Need today and tomorrow both visible (skip Saturday) const endDate = thursdayAt(2);
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week'); const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
await createTimeEntryWithTimestampsViaApi(ctx, { await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Multi day drag up test', description: 'Multi day drag up test',
@@ -2043,12 +1934,9 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page); await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00'); await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on tomorrow's column (00:00-02:00) // Find the clipped segment on Thursday's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
.toISOString() const event = thursdayCol
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol
.locator('.fc-event') .locator('.fc-event')
.filter({ hasText: 'Multi day drag up test' }); .filter({ hasText: 'Multi day drag up test' });
await expect(event).toBeVisible({ timeout: 10000 }); await expect(event).toBeVisible({ timeout: 10000 });

View File

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

View File

@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue'; import type { SortColumn, SortDirection } from '@/Components/Common/Member/MemberTable.vue';
@@ -14,8 +12,6 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
sort: [column: SortColumn]; sort: [column: SortColumn];
}>(); }>();
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -37,10 +33,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
column="billable_rate" column="billable_rate"
v-bind="props" v-bind="props"
@sort="$emit('sort', $event)"> @sort="$emit('sort', $event)">
<span class="inline-flex items-center gap-2 whitespace-nowrap"> Billable Rate
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</span>
</SortableTableHeaderCell> </SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)"> <SortableTableHeaderCell column="status" v-bind="props" @sort="$emit('sort', $event)">
Status Status

View File

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

View File

@@ -23,10 +23,8 @@ import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibility
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser'; import { getCurrentOrganizationId } from '@/utils/useUser';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
const { updateProject } = useProjectsStore(); const { updateProject } = useProjectsStore();
const { locked: billableRatesLocked } = useBillableRatesLock();
const { clients } = useClientsQuery(); const { clients } = useClientsQuery();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!); const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
@@ -50,11 +48,6 @@ const project = ref<CreateProjectBody>({
is_public: props.originalProject.is_public, is_public: props.originalProject.is_public,
}); });
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
function getUpdateBody() {
const { billable_rate: billableRate, ...body } = project.value;
return billableRatesLocked.value ? body : { ...body, billable_rate: billableRate };
}
async function submit() { async function submit() {
if (props.originalProject.billable_rate !== project.value.billable_rate) { if (props.originalProject.billable_rate !== project.value.billable_rate) {
// make sure that the alert modal is not immediately submitted when user presses enter // make sure that the alert modal is not immediately submitted when user presses enter
@@ -63,7 +56,7 @@ async function submit() {
}, 0); }, 0);
return; return;
} }
await updateProject(props.originalProject.id, getUpdateBody()); await updateProject(props.originalProject.id, project.value);
show.value = false; show.value = false;
} }
@@ -79,7 +72,7 @@ const currentClientName = computed(() => {
}); });
async function submitBillableRate() { async function submitBillableRate() {
await updateProject(props.originalProject.id, getUpdateBody()); await updateProject(props.originalProject.id, project.value);
show.value = false; show.value = false;
showBillableRateModal.value = false; showBillableRateModal.value = false;
} }

View File

@@ -10,13 +10,21 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn = export type SortColumn =
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility'; | 'name'
| 'tasks'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
export type { SortDirection } from '@/utils/useSortableTable'; export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions'; import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src'; import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients'; import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery'; import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -56,6 +64,16 @@ const clientNameMap = computed(() => {
return map; return map;
}); });
const { tasks } = useTasksQuery();
const taskCountMap = computed(() => {
const map = new Map<string, number>();
tasks.value.forEach((task) => {
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
});
return map;
});
// Define column accessors for sorting. // Define column accessors for sorting.
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first, // Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
// while text columns default to ascending (A-Z) on first click (chevron down). // while text columns default to ascending (A-Z) on first click (chevron down).
@@ -64,6 +82,11 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
id: 'name', id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(), accessorFn: (row: Project) => row.name.toLowerCase(),
}, },
{
id: 'tasks',
sortDescFirst: true,
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
},
{ {
id: 'client_name', id: 'client_name',
accessorFn: (row: Project) => { accessorFn: (row: Project) => {
@@ -161,7 +184,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
} }
const gridTemplate = computed(() => { const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`; return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
}); });
</script> </script>

View File

@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { computed } from 'vue'; import { computed } from 'vue';
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue'; import SortableTableHeaderCell from '@/Components/Common/SortableTableHeaderCell.vue';
@@ -27,8 +25,6 @@ const sortState = computed(() => ({
function handleSort(column: SortColumn) { function handleSort(column: SortColumn) {
emit('sort', column); emit('sort', column);
} }
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -40,6 +36,9 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
@sort="handleSort"> @sort="handleSort">
Name Name
</SortableTableHeaderCell> </SortableTableHeaderCell>
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
Tasks
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort"> <SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client Client
</SortableTableHeaderCell> </SortableTableHeaderCell>
@@ -54,10 +53,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
column="billable_rate" column="billable_rate"
v-bind="sortState" v-bind="sortState"
@sort="handleSort"> @sort="handleSort">
<span class="inline-flex items-center gap-2 whitespace-nowrap"> Billable Rate
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</span>
</SortableTableHeaderCell> </SortableTableHeaderCell>
<SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort"> <SortableTableHeaderCell column="status" v-bind="sortState" @sort="handleSort">
Status Status

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue'; import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src'; import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -16,7 +15,6 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsStore } from '@/utils/useProjects'; import { useProjectsStore } from '@/utils/useProjects';
import TableRow from '@/Components/TableRow.vue'; import TableRow from '@/Components/TableRow.vue';
import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue'; import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue'; import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
@@ -54,11 +52,8 @@ function deleteProject() {
} }
function archiveProject() { function archiveProject() {
// Locked organizations do not receive the rate of the project, so sending it back would remove the existing rate
const { billable_rate: billableRate, ...project } = props.project;
useProjectsStore().updateProject(props.project.id, { useProjectsStore().updateProject(props.project.id, {
...project, ...props.project,
...(billableRatesLocked.value ? {} : { billable_rate: billableRate }),
is_archived: !props.project.is_archived, is_archived: !props.project.is_archived,
}); });
} }
@@ -83,8 +78,6 @@ const billableRateInfo = computed(() => {
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -95,17 +88,19 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<ContextMenuTrigger as-child> <ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })"> <TableRow :href="route('projects.show', { project: project.id })">
<div <div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6"> class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
<div <div
:style="{ :style="{ backgroundColor: project.color }"
backgroundColor: project.color, class="w-2.5 h-2.5 ml-1 rounded-full"></div>
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 ml-1 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden"> <span class="overflow-ellipsis overflow-hidden">
{{ project.name }} {{ project.name }}
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<span v-if="projectTasksCount">
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
</span>
<span v-else class="text-text-tertiary">--</span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
@@ -136,8 +131,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary"> class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<UpgradeLockedBadge v-if="billableRatesLocked" /> <span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span> <span v-else class="text-text-tertiary">--</span>
</div> </div>
<div <div

View File

@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import TableHeading from '@/Components/Common/TableHeading.vue'; import TableHeading from '@/Components/Common/TableHeading.vue';
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -11,10 +7,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12"> <div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name Name
</div> </div>
<div class="px-3 py-1.5 text-left text-text-tertiary flex items-center gap-2"> <div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
Billable Rate
<UpgradeLockedBadge v-if="billableRatesLocked" />
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div> <div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12"> <div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span> <span class="sr-only">Edit</span>

View File

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

View File

@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import { import {
ChartBarIcon, ChartBarIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
@@ -292,8 +290,6 @@ const tableData = computed(() => {
}; };
}); });
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -420,12 +416,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''"> <div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">
Duration Duration
</div> </div>
<div <div v-if="showBillableRate" class="text-right pr-6">Cost</div>
v-if="showBillableRate"
class="flex items-center justify-end gap-2 pr-6">
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
</div> </div>
<template <template
v-if=" v-if="

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">
<slot>{{ value ?? '--' }}</slot> {{ value ?? '--' }}
</dd> </dd>
</div> </div>
</template> </template>

View File

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

View File

@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue'; import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import { import {
@@ -128,8 +126,6 @@ const showBillableRate = computed(() => {
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
); );
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -155,10 +151,7 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm"> class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
<div class="pl-6">Name</div> <div class="pl-6">Name</div>
<div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div> <div class="text-right" :class="!showBillableRate ? 'pr-6' : ''">Duration</div>
<div v-if="showBillableRate" class="flex items-center justify-end gap-2 pr-6"> <div v-if="showBillableRate" class="text-right pr-6">Cost</div>
<UpgradeLockedBadge v-if="billableRatesLocked" />
Cost
</div>
</div> </div>
<div <div

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 { computed, nextTick, onMounted, provide, ref } from 'vue'; import { nextTick, onMounted, provide, ref } from 'vue';
import NotificationContainer from '@/Components/NotificationContainer.vue'; import NotificationContainer from '@/Components/NotificationContainer.vue';
import { initializeStores } from '@/utils/init'; import { initializeStores } from '@/utils/init';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
@@ -37,9 +37,7 @@ import {
canViewReport, canViewReport,
canViewTags, canViewTags,
} from '@/utils/permissions'; } from '@/utils/permissions';
import { canUseBillableRates, isBillingActivated, isInvoicingActivated } from '@/utils/billing'; import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
import { billableRatesLockKey } from '@/packages/ui/src/utils/useBillableRatesLock';
import type { User } from '@/types/models'; import type { User } from '@/types/models';
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid'; import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
import { fetchToken, isTokenValid } from '@/utils/session'; import { fetchToken, isTokenValid } from '@/utils/session';
@@ -90,14 +88,6 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
provide('organization', organization); provide('organization', organization);
const showBillableRatesUpgradeModal = ref(false);
provide(billableRatesLockKey, {
locked: computed(() => !canUseBillableRates()),
requestUpgrade: () => {
showBillableRatesUpgradeModal.value = true;
},
});
onMounted(async () => { onMounted(async () => {
useTheme(); useTheme();
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia // make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
@@ -387,7 +377,4 @@ const page = usePage<{
<NotificationContainer></NotificationContainer> <NotificationContainer></NotificationContainer>
<UserTimezoneMismatchModal></UserTimezoneMismatchModal> <UserTimezoneMismatchModal></UserTimezoneMismatchModal>
<CommandPaletteProvider></CommandPaletteProvider> <CommandPaletteProvider></CommandPaletteProvider>
<UpgradeModal v-model:show="showBillableRatesUpgradeModal">
<strong>Billable Rates</strong> are only available in solidtime Professional.
</UpgradeModal>
</template> </template>

View File

@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import UpgradeLockedBadge from '@/packages/ui/src/UpgradeLockedBadge.vue';
import { useBillableRatesLock } from '@/packages/ui/src/utils/useBillableRatesLock';
import MainContainer from '@/packages/ui/src/MainContainer.vue'; import MainContainer from '@/packages/ui/src/MainContainer.vue';
import AppLayout from '@/Layouts/AppLayout.vue'; import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid'; import { FolderIcon, PlusIcon } from '@heroicons/vue/20/solid';
@@ -82,8 +80,6 @@ const shownTasks = computed(() => {
return task.project_id === projectId && task.is_done; return task.project_id === projectId && task.is_done;
}); });
}); });
const { locked: billableRatesLocked } = useBillableRatesLock();
</script> </script>
<template> <template>
@@ -121,17 +117,11 @@ const { locked: billableRatesLocked } = useBillableRatesLock();
</li> </li>
</ol> </ol>
<div class="px-4 space-x-1"> <div class="px-4 space-x-1">
<UpgradeLockedBadge v-if="billableRatesLocked" <Badge v-if="project?.billable_rate">
>Billable Rates</UpgradeLockedBadge
>
<Badge v-else-if="project?.billable_rate">
{{ billableRateFormatted }} {{ billableRateFormatted }}
/ h / h
</Badge> </Badge>
<Badge <Badge v-if="project?.is_billable && !project?.billable_rate">
v-if="
project?.is_billable && !project?.billable_rate && !billableRatesLocked
">
Default Rate Default Rate
</Badge> </Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge> <Badge v-if="!project?.is_billable"> Non-Billable </Badge>

View File

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

View File

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

View File

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

View File

@@ -10,15 +10,12 @@ import {
} from '@/packages/ui/src/tooltip'; } from '@/packages/ui/src/tooltip';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useBillableRatesLock } from '../utils/useBillableRatesLock';
const props = defineProps<{ const props = defineProps<{
currency: string; currency: string;
organizationBillableRate: number | null; organizationBillableRate: number | null;
}>(); }>();
const { locked: billableRatesLocked } = useBillableRatesLock();
type RateType = 'default-rate' | 'custom-rate'; type RateType = 'default-rate' | 'custom-rate';
const billableDefault = ref<'billable' | 'non-billable'>('non-billable'); const billableDefault = ref<'billable' | 'non-billable'>('non-billable');
@@ -94,12 +91,7 @@ const emit = defineEmits(['submit']);
</Field> </Field>
<Field> <Field>
<FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel> <FieldLabel :icon="BillableIcon" for="billableRateType">Billable Rate</FieldLabel>
<BillableRateInput <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
v-if="billableRatesLocked"
v-model="displayedRate"
:currency="currency"
name="billableRate" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Select v-model="rateType"> <Select v-model="rateType">
<SelectTrigger id="billableRateType"> <SelectTrigger id="billableRateType">
<SelectValue /> <SelectValue />

View File

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

View File

@@ -21,8 +21,6 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
export type { ActivityPeriod } from './FullCalendar/activityTypes'; export type { ActivityPeriod } from './FullCalendar/activityTypes';
export { cn } from './utils/cn'; export { cn } from './utils/cn';
export { useCssVariable } from './utils/useCssVariable'; export { useCssVariable } from './utils/useCssVariable';
export { billableRatesLockKey, useBillableRatesLock } from './utils/useBillableRatesLock';
export type { BillableRatesLock } from './utils/useBillableRatesLock';
import Badge from './Badge.vue'; import Badge from './Badge.vue';
import Button from './Buttons/Button.vue'; import Button from './Buttons/Button.vue';

View File

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

View File

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

View File

@@ -89,7 +89,6 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('hasTrial')->andReturn(false); $mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
} }
@@ -100,18 +99,6 @@ abstract class TestCase extends BaseTestCase
$mock->shouldReceive('hasTrial')->andReturn(false); $mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
});
}
protected function actAsOrganizationWithoutBillableRates(): void
{
$this->mock(BillingContract::class, function (MockInterface $mock): void {
$mock->shouldReceive('hasSubscription')->andReturn(false);
$mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(false);
}); });
} }
} }

View File

@@ -1,286 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Http\Controllers\Api\V1\Controller;
use App\Models\Member;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Carbon;
use Laravel\Passport\Passport;
use PHPUnit\Framework\Attributes\UsesClass;
#[UsesClass(Controller::class)]
class BillableRateRestrictionEndpointTest extends ApiEndpointTestAbstract
{
public function test_project_store_fails_with_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('error', true);
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
$this->assertDatabaseMissing(Project::class, [
'name' => 'Project',
]);
}
public function test_project_store_without_billable_rate_works_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => 'Project',
'color' => '#ef5350',
'client_id' => null,
'is_billable' => true,
'billable_rate' => null,
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Project::class, [
'name' => 'Project',
'is_billable' => true,
'billable_rate' => null,
]);
}
public function test_project_update_keeps_existing_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
]);
Passport::actingAs($data->user);
// Act
$responseKeep = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'RenamedKeep',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 10000,
]);
$responseChange = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'RenamedChange',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => 20000,
]);
$responseRemove = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'RenamedRemove',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'billable_rate' => null,
]);
// Assert
$responseKeep->assertStatus(200);
$responseKeep->assertJsonPath('data.name', 'RenamedKeep');
$responseChange->assertStatus(400);
$responseChange->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
$responseRemove->assertStatus(200);
$project->refresh();
$this->assertSame('RenamedRemove', $project->name);
$this->assertSame(10000, $project->billable_rate);
}
public function test_project_update_without_billable_rate_does_not_change_project_rate_or_time_entries_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create([
'billable_rate' => 10000,
'is_billable' => true,
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->create([
'billable' => true,
'billable_rate' => 10000,
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Renamed',
'color' => $project->color,
'client_id' => null,
'is_billable' => true,
'is_archived' => true,
]);
// Assert
$response->assertStatus(200);
$project->refresh();
$this->assertSame('Renamed', $project->name);
$this->assertTrue($project->is_archived);
$this->assertSame(10000, $project->billable_rate);
$this->assertSame(10000, $timeEntry->refresh()->billable_rate);
}
public function test_organization_update_fails_to_set_billable_rate_or_employee_visibility_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'organizations:update',
]);
$data->organization->employees_can_see_billable_rates = false;
$data->organization->save();
Passport::actingAs($data->user);
// Act
$responseRate = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => 10000,
]);
$responseVisibility = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'employees_can_see_billable_rates' => true,
]);
$responseUnchanged = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => 'Organization',
'billable_rate' => null,
'employees_can_see_billable_rates' => false,
]);
// Assert
$responseRate->assertStatus(400);
$responseVisibility->assertStatus(400);
$responseUnchanged->assertStatus(200);
$data->organization->refresh();
$this->assertNull($data->organization->billable_rate);
$this->assertFalse($data->organization->employees_can_see_billable_rates);
}
public function test_member_update_fails_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'members:update',
]);
$member = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create([
'billable_rate' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $member->getKey()]), [
'billable_rate' => 10000,
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('key', 'feature_is_not_available_in_free_plan');
$this->assertNull($member->refresh()->billable_rate);
}
public function test_project_member_store_and_update_fail_to_set_billable_rate_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'project-members:create',
'project-members:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser(User::factory()->create())->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create([
'billable_rate' => null,
]);
Passport::actingAs($data->user);
// Act
$responseStore = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
'member_id' => $otherMember->getKey(),
'billable_rate' => 10000,
]);
$responseUpdate = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [
'billable_rate' => 10000,
]);
// Assert
$responseStore->assertStatus(400);
$responseUpdate->assertStatus(400);
$this->assertNull($projectMember->refresh()->billable_rate);
$this->assertDatabaseMissing(ProjectMember::class, [
'member_id' => $otherMember->getKey(),
]);
}
public function test_aggregate_endpoint_hides_cost_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$start = Carbon::now()->timezone($data->user->timezone)->subDays(2);
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($start, 3600)->create([
'billable' => true,
'billable_rate' => 10000,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
]));
// Assert
$response->assertSuccessful();
$response->assertJsonPath('data.seconds', 3600);
$response->assertJsonPath('data.cost', null);
}
public function test_total_weekly_billable_amount_is_forbidden_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$data = $this->createUserWithPermission([
'charts:view:own',
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.charts.total-weekly-billable-amount', [$data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
}

View File

@@ -35,7 +35,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(false); $mock->shouldReceive('hasTrial')->andReturn(false);
$mock->shouldReceive('getTrialUntil')->andReturn(null); $mock->shouldReceive('getTrialUntil')->andReturn(null);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -48,7 +47,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', false) ->where('billing.has_trial', false)
->where('billing.trial_until', null) ->where('billing.trial_until', null)
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.can_use_billable_rates', true)
); );
} }
@@ -63,7 +61,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
$mock->shouldReceive('hasTrial')->andReturn(true); $mock->shouldReceive('hasTrial')->andReturn(true);
$mock->shouldReceive('getTrialUntil')->andReturn($trialUntil); $mock->shouldReceive('getTrialUntil')->andReturn($trialUntil);
$mock->shouldReceive('isBlocked')->andReturn(false); $mock->shouldReceive('isBlocked')->andReturn(false);
$mock->shouldReceive('canUseBillableRates')->andReturn(true);
}); });
Passport::actingAs($user->user); Passport::actingAs($user->user);
@@ -76,7 +73,6 @@ class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
->where('billing.has_trial', true) ->where('billing.has_trial', true)
->where('billing.trial_until', $trialUntil->toIso8601ZuluString()) ->where('billing.trial_until', $trialUntil->toIso8601ZuluString())
->where('billing.is_blocked', false) ->where('billing.is_blocked', false)
->where('billing.can_use_billable_rates', true)
); );
} }
} }

View File

@@ -8,8 +8,6 @@ use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask; use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -104,34 +102,6 @@ class SolidtimeImporterTest extends ImporterTestAbstract
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1); Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
} }
public function test_import_drops_billable_rates_if_organization_can_not_use_billable_rates(): void
{
// Arrange
$this->actAsOrganizationWithoutBillableRates();
$zipPath = $this->createTestZip('solidtime_import_test_1');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
$importer->importData($data, $timezone);
// Assert
$this->assertSame(3, Project::query()->whereBelongsTo($organization, 'organization')->whereNull('billable_rate')->count());
$this->assertSame(1, ProjectMember::query()->whereBelongsToOrganization($organization)->whereNull('billable_rate')->count());
$timeEntries = TimeEntry::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $timeEntries);
foreach ($timeEntries as $timeEntry) {
$this->assertNull($timeEntry->billable_rate);
}
}
public function test_import_of_test_file_with_type_column_imports_breaks(): void public function test_import_of_test_file_with_type_column_imports_breaks(): void
{ {
// Arrange // Arrange