Compare commits

..

18 Commits

Author SHA1 Message Date
Gregor Vostrak
4b5aff20fc bump solidtime ui package version to 0.0.13 2025-11-19 17:00:11 +01:00
Gregor Vostrak
9e5aa77e41 fix display problems caused by minimum height of calendar events 2025-11-19 16:46:58 +01:00
Gregor Vostrak
0791a68283 add support for currently running time entry 2025-11-19 16:08:32 +01:00
Gregor Vostrak
e66679274d improve idle indicator colors, fix typescript issues 2025-11-19 13:37:33 +01:00
Gregor Vostrak
717fd35d76 add tooltips to idlestatus indicators 2025-11-18 13:58:30 +01:00
Gregor Vostrak
5a3a5995cc add activity status plugin to calendar 2025-11-17 14:20:04 +01:00
Gregor Vostrak
a8e6d28eab improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-13 15:20:30 +01:00
Gregor Vostrak
9c9aeeab0f use container queries for time entry table 2025-11-13 12:24:28 +01:00
Gregor Vostrak
8a1253e101 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-12 18:19:27 +01:00
Gregor Vostrak
661fa25da1 prevent seconds update on timepicker when nothing else changes 2025-11-12 18:15:59 +01:00
Gregor Vostrak
d77048a7dd add tooltip component 2025-11-12 18:01:02 +01:00
Gregor Vostrak
4676af9b40 move css variables and tailwind theme config into ui package 2025-11-12 16:49:41 +01:00
Gregor Vostrak
18c8e62228 make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-12 14:33:56 +01:00
Gregor Vostrak
e7703aef64 move button component to ui package 2025-11-12 14:24:54 +01:00
Gregor Vostrak
86d0497000 design fixes, improve component encapsulation 2025-11-06 14:20:12 +01:00
Gregor Vostrak
522f7d2bd2 move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-04 16:08:24 +01:00
Gregor Vostrak
2f807e4808 fix package build error dependencies 2025-11-04 15:48:14 +01:00
Gregor Vostrak
93d9db349b bump api and ui package versions 2025-11-04 15:15:26 +01:00
102 changed files with 1139 additions and 2834 deletions

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TimeEntryResource\Pages;
use App\Models\Member;
use App\Models\TimeEntry;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -17,7 +16,6 @@ use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class TimeEntryResource extends Resource
{
@@ -53,20 +51,6 @@ class TimeEntryResource extends Resource
->rules([
'after_or_equal:start',
]),
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
Select::make('member_id')
->relationship(
name: 'member',
titleAttribute: 'id',
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
)
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
->searchable()
->preload()
->required(),
Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email')
->searchable(['name', 'email'])
@@ -75,10 +59,7 @@ class TimeEntryResource extends Resource
->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
Select::make('task_id')
->relationship(name: 'task', titleAttribute: 'name')
->searchable(['name'])
->nullable(),
// TODO
]);
}

View File

@@ -5,28 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Resources\Pages\CreateRecord;
class CreateTimeEntry extends CreateRecord
{
protected static string $resource = TimeEntryResource::class;
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\TimeEntryResource\Pages;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
@@ -20,22 +19,4 @@ class EditTimeEntry extends EditRecord
->icon('heroicon-m-trash'),
];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['member_id'])) {
/** @var Member|null $member */
$member = Member::query()->find($data['member_id']);
if ($member !== null) {
$data['user_id'] = $member->user_id;
$data['organization_id'] = $member->organization_id;
}
}
return $data;
}
}

View File

@@ -46,9 +46,6 @@ class OrganizationController extends Controller
if ($request->getEmployeesCanSeeBillableRates() !== null) {
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
}
if ($request->getEmployeesCanManageTasks() !== null) {
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
}
if ($request->getNumberFormat() !== null) {
$organization->number_format = $request->getNumberFormat();
}

View File

@@ -11,7 +11,6 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
use App\Http\Resources\V1\Task\TaskCollection;
use App\Http\Resources\V1\Task\TaskResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -28,26 +27,6 @@ class TaskController extends Controller
}
}
/**
* Check scoped permission and verify user has access to the project
*
* @throws AuthorizationException
*/
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
{
$this->checkPermission($organization, $permission);
$user = $this->user();
$hasAccess = Project::query()
->where('id', $project->id)
->visibleByEmployee($user)
->exists();
if (! $hasAccess) {
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
}
}
/**
* Get tasks
*
@@ -96,15 +75,7 @@ class TaskController extends Controller
*/
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{
/** @var Project $project */
$project = Project::query()->findOrFail($request->input('project_id'));
if ($this->hasPermission($organization, 'tasks:create:all')) {
$this->checkPermission($organization, 'tasks:create:all');
} else {
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
}
$this->checkPermission($organization, 'tasks:create');
$task = new Task;
$task->name = $request->input('name');
$task->project_id = $request->input('project_id');
@@ -126,17 +97,7 @@ class TaskController extends Controller
*/
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{
// Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:update:all')) {
$this->checkPermission($organization, 'tasks:update:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
}
$this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
@@ -158,16 +119,7 @@ class TaskController extends Controller
*/
public function destroy(Organization $organization, Task $task): JsonResponse
{
// Check task belongs to organization
if ($task->organization_id !== $organization->id) {
throw new AuthorizationException('Task does not belong to organization');
}
if ($this->hasPermission($organization, 'tasks:delete:all')) {
$this->checkPermission($organization, 'tasks:delete:all');
} else {
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
}
$this->checkPermission($organization, 'tasks:delete', $task);
if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry');

View File

@@ -39,9 +39,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
@@ -105,11 +102,6 @@ class OrganizationUpdateRequest extends BaseFormRequest
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getEmployeesCanManageTasks(): ?bool
{
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -44,16 +42,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -56,16 +54,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -10,10 +10,8 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -44,16 +42,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'required_with:task_id',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
$permissionStore = app(PermissionStore::class);
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
$builder = $builder->visibleByEmployee(Auth::user());
}
return $builder;
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// ID of the task that the time entry should belong to

View File

@@ -53,8 +53,6 @@ class OrganizationResource extends BaseResource
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */

View File

@@ -35,7 +35,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property int|null $billable_rate
* @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -71,7 +70,6 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,

View File

@@ -94,11 +94,8 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -161,11 +158,8 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',
@@ -225,11 +219,8 @@ class JetstreamServiceProvider extends ServiceProvider
'tasks:view',
'tasks:view:all',
'tasks:create',
'tasks:create:all',
'tasks:update',
'tasks:update:all',
'tasks:delete',
'tasks:delete:all',
'time-entries:view:all',
'time-entries:create:all',
'time-entries:update:all',

View File

@@ -266,8 +266,7 @@ class DashboardService
) as aggregate'))
->where('billable', '=', true)
->whereNotNull('billable_rate')
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
->where('user_id', '=', $user->id);
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */

View File

@@ -167,7 +167,7 @@ class ExportService
$client->id,
$client->name,
$client->organization_id,
$client->archived_at?->toIso8601ZuluString() ?? '',
$client->archived_at ?? '',
$client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '',
]);

View File

@@ -71,19 +71,7 @@ class PermissionStore
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
$permissions = $roleObj->permissions ?? [];
// If the organization allows employees to manage tasks and the user is an employee,
// add the task management permissions for accessible projects
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
$permissions = array_merge($permissions, [
'tasks:create',
'tasks:update',
'tasks:delete',
]);
}
return $permissions;
return $roleObj->permissions ?? [];
}
/**

View File

@@ -31,17 +31,12 @@ class TimeEntryService
throw new LogicException('Rounding minutes must be greater than 0');
}
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
if ($roundingType === TimeEntryRoundingType::Down) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Up) {
// If end is already on a boundary, keep it; otherwise round up to next boundary
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
'THEN '.$end.' '.
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
'END';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')';
}
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('employees_can_manage_tasks');
});
}
};

View File

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.57.0-jammy
image: mcr.microsoft.com/playwright:v1.51.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -8,13 +8,6 @@ async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -52,62 +45,34 @@ test('test that creating and deleting a new project via the modal works', async
await expect(page.getByTestId('project_table')).not.toContainText(newProjectName);
});
// Helper to select a status filter using the new dropdown UI
async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') {
// Click the Filter button to open the dropdown
await page.getByRole('button', { name: 'Filter projects' }).click();
// Click on Status submenu
await page.getByRole('menuitem', { name: 'Status' }).click();
// Select the status option
await page.getByRole('menuitem', { name: status }).click();
}
// Helper to remove status filter by clicking the X on the badge
async function removeStatusFilter(page: Page) {
const statusBadge = page.getByTestId('status-filter-badge');
// Click the remove button (second button in the badge, contains XMarkIcon)
await statusBadge.locator('button').last().click();
}
test('test that archiving and unarchiving projects works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Archive').first().click();
await Promise.all([
page.getByRole('menuitem').getByText('Archive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Archived' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
// Project should still be visible since default is "all" (no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter and apply Archived filter
await removeStatusFilter(page);
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Unarchive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Unarchive').first().click();
// Project should disappear from Archived view
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Archived filter and apply Active filter to see the project
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).toBeVisible();
await Promise.all([
page.getByRole('menuitem').getByText('Unarchive').first().click(),
expect(page.getByText(newProjectName)).not.toBeVisible(),
]);
await Promise.all([
page.getByRole('tab', { name: 'Active' }).click(),
expect(page.getByText(newProjectName)).toBeVisible(),
]);
});
test('test that updating billable rate works with existing time entries', async ({ page }) => {
@@ -151,147 +116,6 @@ test('test that updating billable rate works with existing time entries', async
).toBeVisible();
});
// Sorting tests
test('test that sorting projects by name works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Get initial project names
const getProjectNames = async () => {
const rows = page
.getByTestId('project_table')
.locator('[data-testid="project_table"] > div')
.filter({ hasNot: page.locator('.border-t') });
const names: string[] = [];
const count = await page.getByTestId('project_table').getByRole('row').count();
for (let i = 0; i < count; i++) {
const row = page.getByTestId('project_table').getByRole('row').nth(i);
const nameCell = row.locator('div').first();
const text = await nameCell.textContent();
if (text) {
names.push(text.trim());
}
}
return names;
};
// Click on Name header to sort ascending (default should already be ascending)
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Click again to sort descending
await nameHeader.click();
await page.waitForTimeout(100);
// Verify the sort indicator is showing descending
await expect(page.locator('svg').first()).toBeVisible();
});
test('test that sorting projects by status works', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Default is "all" so no filter needed - Wait for the table to load
await expect(page.getByTestId('project_table')).toBeVisible();
// Click on Status header to sort
const statusHeader = page.getByText('Status').first();
await statusHeader.click();
// Wait for sort to apply
await page.waitForTimeout(100);
// Sort indicator should be visible
await expect(statusHeader.locator('svg')).toBeVisible();
});
// Filter tests
test('test that filtering projects by status works', async ({ page }) => {
const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Create a new project
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
await page.getByRole('button', { name: 'Create Project' }).click();
await expect(page.getByText(newProjectName)).toBeVisible();
// Archive the project
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Archive').first().click();
// Project should still be visible (default is "all" - no filter)
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Active filter - archived project should disappear
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
// Remove Active filter - project should reappear (back to "all")
await removeStatusFilter(page);
await expect(page.getByText(newProjectName)).toBeVisible();
// Apply Archived filter - project should still be visible
await selectStatusFilter(page, 'Archived');
await expect(page.getByText(newProjectName)).toBeVisible();
// Remove Archived filter and apply Active filter - project should not be visible
await removeStatusFilter(page);
await selectStatusFilter(page, 'Active');
await expect(page.getByText(newProjectName)).not.toBeVisible();
});
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Apply Active status filter
await selectStatusFilter(page, 'Active');
// Verify the filter badge is visible
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify the filter badge is still visible after reload
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearProjectTableState(page);
await page.reload();
// Click on Name header twice to sort descending
const nameHeader = page.getByText('Name').first();
await nameHeader.click();
await nameHeader.click();
// Wait for the state to be saved
await page.waitForTimeout(100);
// Reload the page
await page.reload();
// Verify descending sort indicator is visible on Name column
await expect(page.getByTestId('project_table')).toBeVisible();
});
// Create new project with new Client
// Create new project with existing Client
@@ -300,6 +124,8 @@ test('test that sort state persists after page reload', async ({ page }) => {
// Test that project task count is displayed correctly
// Test that active / archive / all filter works (once implemented)
// Edit Project Modal Test
// Add Project with billable rate

View File

@@ -4,11 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<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 font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<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>
</div>

View File

@@ -4,10 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Email
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>

View File

@@ -4,13 +4,14 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<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 font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</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">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>

View File

@@ -1,48 +0,0 @@
<script setup lang="ts">
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
defineProps<{
icon: Component;
label: string;
filterName: string;
}>();
defineEmits<{
remove: [];
}>();
defineSlots<{
default(): void;
}>();
</script>
<template>
<div
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
<DropdownMenu>
<DropdownMenuTrigger
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors whitespace-nowrap">
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
<span class="font-medium text-foreground">{{ filterName }}</span>
<span class="text-muted-foreground">is</span>
<span class="text-foreground">{{ label }}</span>
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<slot />
</DropdownMenuContent>
</DropdownMenu>
<button
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
@click="$emit('remove')">
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
</template>

View File

@@ -1,68 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserGroupIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
const props = defineProps<{
value: string[];
clients: Client[];
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: string[]];
}>();
const hasNoClient = computed(() => props.value.includes(NO_CLIENT_ID));
const label = computed(() => {
const count = props.value.length;
if (count === 0) return 'None';
if (count === 1) {
if (hasNoClient.value) return 'No client';
const client = props.clients.find((c) => c.id === props.value[0]);
return client?.name ?? 'Client';
}
return `${count} selected`;
});
function toggleClient(clientId: string) {
const clientIds = props.value.includes(clientId)
? props.value.filter((id) => id !== clientId)
: [...props.value, clientId];
emit('update:value', clientIds);
}
function toggleNoClient() {
const clientIds = hasNoClient.value
? props.value.filter((id) => id !== NO_CLIENT_ID)
: [...props.value, NO_CLIENT_ID];
emit('update:value', clientIds);
}
</script>
<template>
<BaseFilterBadge
:icon="UserGroupIcon"
:label="label"
filter-name="Client"
@remove="emit('remove')">
<DropdownMenuCheckboxItem :model-value="hasNoClient" @select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="value.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</BaseFilterBadge>
</template>

View File

@@ -130,7 +130,7 @@ function updateValue(project: Project) {
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor>

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CircleStackIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/Components/ui/dropdown-menu';
import BaseFilterBadge from './BaseFilterBadge.vue';
type StatusValue = 'active' | 'archived' | 'all';
const props = defineProps<{
value: StatusValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: StatusValue];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const label = computed(() => {
return statusOptions.find((opt) => opt.id === props.value)?.name ?? 'Status';
});
function updateStatus(status: StatusValue) {
emit('update:value', status);
}
</script>
<template>
<BaseFilterBadge
:icon="CircleStackIcon"
:label="label"
filter-name="Status"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -4,10 +4,7 @@ import { FolderPlusIcon } from '@heroicons/vue/24/solid';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { computed, ref } from 'vue';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import ProjectTableHeading, {
type SortColumn,
type SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableHeading from '@/Components/Common/Project/ProjectTableHeading.vue';
import ProjectTableRow from '@/Components/Common/Project/ProjectTableRow.vue';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -15,96 +12,13 @@ import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import {
useVueTable,
getCoreRowModel,
getSortedRowModel,
type SortingState,
} from '@tanstack/vue-table';
const props = defineProps<{
projects: Project[];
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
const { clients } = storeToRefs(useClientsStore());
// Create a map of client names for sorting
const clientNameMap = computed(() => {
const map = new Map<string, string>();
clients.value.forEach((client) => {
map.set(client.id, client.name);
});
return map;
});
// Convert our sort state to TanStack Table format
const sorting = computed<SortingState>(() => [
{
id: props.sortColumn,
desc: props.sortDirection === 'desc',
},
]);
// Define column accessors for sorting
const columns = [
{
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'client_name',
accessorFn: (row: Project) => {
if (!row.client_id) return '';
return (clientNameMap.value.get(row.client_id) ?? '').toLowerCase();
},
},
{
id: 'spent_time',
accessorFn: (row: Project) => row.spent_time ?? 0,
},
{
id: 'billable_rate',
accessorFn: (row: Project) => row.billable_rate ?? 0,
},
{
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
];
const table = useVueTable({
get data() {
return props.projects;
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting.value;
},
},
manualSorting: false,
});
const sortedProjects = computed(() => {
return table.getRowModel().rows.map((row) => row.original);
});
function handleSort(column: SortColumn) {
emit('sort', column);
}
const showCreateProjectModal = ref(false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
@@ -112,10 +26,11 @@ async function createProject(project: CreateProjectBody): Promise<Project | unde
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
const { clients } = storeToRefs(useClientsStore());
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) 80px;`;
});
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
</script>
<template>
@@ -130,11 +45,8 @@ const gridTemplate = computed(() => {
<div class="inline-block min-w-full align-middle">
<div data-testid="project_table" class="grid min-w-full" :style="gridTemplate">
<ProjectTableHeading
:show-billable-rate="props.showBillableRate"
:sort-column="props.sortColumn"
:sort-direction="props.sortDirection"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
:show-billable-rate="props.showBillableRate"></ProjectTableHeading>
<div v-if="projects.length === 0" class="col-span-5 py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
{{
@@ -157,7 +69,7 @@ const gridTemplate = computed(() => {
>Create your First Project
</SecondaryButton>
</div>
<template v-for="project in sortedProjects" :key="project.id">
<template v-for="project in projects" :key="project.id">
<ProjectTableRow
:show-billable-rate="props.showBillableRate"
:project="project"></ProjectTableRow>

View File

@@ -1,89 +1,23 @@
<script setup lang="ts">
import TableHeading from '@/Components/Common/TableHeading.vue';
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
export type SortColumn = 'name' | 'client_name' | 'spent_time' | 'billable_rate' | 'status';
export type SortDirection = 'asc' | 'desc';
const props = defineProps<{
defineProps<{
showBillableRate: boolean;
sortColumn: SortColumn;
sortDirection: SortDirection;
}>();
const emit = defineEmits<{
sort: [column: SortColumn];
}>();
function handleSort(column: SortColumn) {
emit('sort', column);
}
function isSorted(column: SortColumn): boolean {
return props.sortColumn === column;
}
</script>
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12 cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('name')">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
<ChevronDownIcon v-if="isSorted('name') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('client_name')">
Client
<ChevronDownIcon
v-if="isSorted('client_name') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('client_name') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('spent_time')">
Total Time
<ChevronDownIcon
v-if="isSorted('spent_time') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('spent_time') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('billable_rate')">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div v-if="showBillableRate" class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
<ChevronDownIcon
v-if="isSorted('billable_rate') && sortDirection === 'asc'"
class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('billable_rate') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('status')">
Status
<ChevronDownIcon v-if="isSorted('status') && sortDirection === 'asc'" class="w-4 h-4" />
<ChevronUpIcon
v-else-if="isSorted('status') && sortDirection === 'desc'"
class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<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>
</div>

View File

@@ -2,7 +2,7 @@
import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreOptionsDropdown.vue';
import type { Project } from '@/packages/api/src';
import { computed, ref, inject, type ComputedRef } from 'vue';
import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import { useTasksStore } from '@/utils/useTasks';
@@ -116,15 +116,9 @@ const showEditProjectModal = ref(false);
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1 items-center font-medium">
<CheckCircleIcon class="w-5"></CheckCircleIcon>
<span>Active</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from '@/Components/ui/dropdown-menu';
import { Button } from '@/packages/ui/src';
import type { Client } from '@/packages/api/src';
import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
clientIds: string[];
}
const props = defineProps<{
filters: ProjectFilters;
clients: Client[];
}>();
const emit = defineEmits<{
'update:filters': [filters: ProjectFilters];
}>();
const statusOptions = [
{ id: 'active' as const, name: 'Active' },
{ id: 'archived' as const, name: 'Archived' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
emit('update:filters', {
...props.filters,
status,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
: [...props.filters.clientIds, clientId];
emit('update:filters', {
...props.filters,
clientIds,
});
}
function toggleNoClient() {
const clientIds = props.filters.clientIds.includes(NO_CLIENT_ID)
? props.filters.clientIds.filter((id) => id !== NO_CLIENT_ID)
: [...props.filters.clientIds, NO_CLIENT_ID];
emit('update:filters', {
...props.filters,
clientIds,
});
}
const hasActiveFilters = computed(() => {
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
});
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="xs" aria-label="Filter projects">
<ListFilterIcon
:class="[hasActiveFilters ? '' : '-ml-0.5', 'h-4 w-4 text-icon-default']" />
<span v-if="!hasActiveFilters" class="text-nowrap">Filter</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<!-- Status Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<CheckCircleIcon class="h-4 w-4 text-icon-default" />
<span>Status</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in statusOptions"
:key="option.id"
:class="[
filters.status === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateStatus(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">
<UserGroupIcon class="h-4 w-4 text-icon-default" />
<span>Client</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="max-h-[300px] overflow-y-auto">
<DropdownMenuCheckboxItem
:model-value="filters.clientIds.includes(NO_CLIENT_ID)"
@select.prevent="toggleNoClient">
No client
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="client in clients"
:key="client.id"
:model-value="filters.clientIds.includes(client.id)"
@select.prevent="toggleClient(client.id)">
{{ client.name }}
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -1 +0,0 @@
export const NO_CLIENT_ID = '__no_client__';

View File

@@ -4,11 +4,12 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<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 font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<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>
</div>

View File

@@ -4,12 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<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 font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Description</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Visibility</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Public URL</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Description</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Visibility</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Public URL</div>
<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>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Switch } from '@/Components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/packages/ui/src';
import {
Select,

View File

@@ -2,7 +2,7 @@
<template>
<div
class="contents [&>*]:border-row-separator text-xs [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
class="contents [&>*]:border-row-separator text-xs sm:text-sm [&>*]:border-b [&>*]:border-t [&>*]:bg-row-heading-background">
<slot></slot>
</div>
</template>

View File

@@ -4,7 +4,8 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<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 font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -4,12 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<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 font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Task Name
</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Total Time</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Progress</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Total Time</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Progress</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<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>
</div>

View File

@@ -1,11 +1,19 @@
<script setup lang="ts">
import { ref } from 'vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { useForm, usePage } from '@inertiajs/vue3';
import type { User } from '@/types/models';
import TimezoneMismatchModal from '@/packages/ui/src/TimezoneMismatchModal.vue';
import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false });
const saving = ref(false);
const saving = defineModel('saving', { default: false });
const timezone = ref('');
const userTimezone = ref('');
const page = usePage<{
auth: {
@@ -13,11 +21,27 @@ const page = usePage<{
};
}>();
function handleUpdate(timezone: string) {
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
onMounted(() => {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
if (
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value
) {
show.value = true;
}
});
function submit() {
saving.value = true;
const form = useForm({
_method: 'PUT',
timezone: timezone,
timezone: timezone.value,
name: page.props.auth.user.name,
email: page.props.auth.user.email,
week_start: page.props.auth.user.week_start,
@@ -31,15 +55,53 @@ function handleUpdate(timezone: string) {
show.value = false;
location.reload();
},
onError: () => {
saving.value = false;
},
});
}
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
}
</script>
<template>
<TimezoneMismatchModal v-model:show="show" :saving="saving" @update="handleUpdate" />
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex justify-center">
<span> Timezone mismatch detected </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
<p>
The timezone of your device does not match the timezone in your user
settings. <br />
<strong
>We highly recommend that you update your timezone settings to your
current timezone.</strong
>
</p>
<p>
Want to change your timezone setting from
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
>.
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Update timezone
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,7 +1,8 @@
<template>
<section class="flex flex-col">
<CardTitle :title="title" :icon="icon"></CardTitle>
<div class="rounded-lg border border-card-border flex-1 flex items-stretch">
<div
class="rounded-lg bg-card-background border border-card-border flex-1 flex items-stretch shadow-card">
<div class="w-full flex flex-col">
<slot></slot>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { AccordionContent, type AccordionContentProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { cn } from '../utils/cn';
import { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-vue-next';
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { Button } from '@/packages/ui/src';
import { Calendar } from '@/Components/ui/calendar';
import { CalendarIcon, XIcon } from 'lucide-vue-next';

View File

@@ -15,7 +15,9 @@ const delegatedProps = computed(() => {
<template>
<TabsList
v-bind="delegatedProps"
:class="cn('inline-flex items-center rounded-lg text-muted-foreground', props.class)">
:class="
cn('inline-flex items-center rounded-lg bg-muted text-muted-foreground', props.class)
">
<slot />
</TabsList>
</template>

View File

@@ -35,7 +35,7 @@ const refreshDashboardData = () => {
</MainContainer>
<MainContainer
class="grid gap-2 sm:gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
class="grid gap-5 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pt-3 sm:pt-5 pb-4 sm:pb-6 border-b border-default-background-separator items-stretch">
<RecentlyTrackedTasksCard></RecentlyTrackedTasksCard>
<LastSevenDaysCard></LastSevenDaysCard>
<ActivityGraphCard></ActivityGraphCard>

View File

@@ -4,16 +4,13 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { FolderIcon, PlusIcon } from '@heroicons/vue/16/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import ProjectTable from '@/Components/Common/Project/ProjectTable.vue';
import type {
SortColumn,
SortDirection,
} from '@/Components/Common/Project/ProjectTableHeading.vue';
import { computed } from 'vue';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { computed, onMounted, ref } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import PageTitle from '@/Components/Common/PageTitle.vue';
import { canCreateProjects } from '@/utils/permissions';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import { storeToRefs } from 'pinia';
import { useClientsStore } from '@/utils/useClients';
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
@@ -21,95 +18,31 @@ import { getOrganizationCurrencyString } from '@/utils/money';
import { getCurrentRole } from '@/utils/useUser';
import { useOrganizationStore } from '@/utils/useOrganization';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
// Fetch data using TanStack Query
const { projects } = useProjectsQuery();
onMounted(() => {
useProjectsStore().fetchProjects();
useOrganizationStore().fetchOrganization();
});
const { clients } = storeToRefs(useClientsStore());
const showCreateProjectModal = ref(false);
const { organization } = storeToRefs(useOrganizationStore());
// Table state persisted in localStorage
interface ProjectTableState {
sortColumn: SortColumn;
sortDirection: SortDirection;
filters: {
clientIds: string[];
status: 'active' | 'archived' | 'all';
};
}
const activeTab = ref<'active' | 'archived'>('active');
const tableState = useStorage<ProjectTableState>(
'project-table-state',
{
sortColumn: 'name',
sortDirection: 'asc',
filters: {
clientIds: [],
status: 'all',
},
},
undefined,
{ mergeDefaults: true }
);
const { projects } = storeToRefs(useProjectsStore());
// Handle sorting - toggle direction if same column, otherwise set new column with asc
function handleSort(column: SortColumn) {
if (tableState.value.sortColumn === column) {
tableState.value.sortDirection = tableState.value.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
tableState.value.sortColumn = column;
tableState.value.sortDirection = 'asc';
}
}
// Filter projects based on current filters
const filteredProjects = computed(() => {
const shownProjects = computed(() => {
return projects.value.filter((project) => {
// Status filter
if (tableState.value.filters.status === 'active' && project.is_archived) {
return false;
if (activeTab.value === 'active') {
return !project.is_archived;
}
if (tableState.value.filters.status === 'archived' && !project.is_archived) {
return false;
}
// Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) {
const matchesNoClient =
tableState.value.filters.clientIds.includes(NO_CLIENT_ID) && !project.client_id;
const matchesClientId =
project.client_id && tableState.value.filters.clientIds.includes(project.client_id);
if (!matchesNoClient && !matchesClientId) {
return false;
}
}
return true;
return project.is_archived;
});
});
// Helper functions for active filters
function removeStatusFilter() {
tableState.value.filters.status = 'all';
}
function removeClientFilter() {
tableState.value.filters.clientIds = [];
}
const showCreateProjectModal = useStorage('project-create-modal-open', false);
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(client: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(client);
}
@@ -124,9 +57,13 @@ const showBillableRate = computed(() => {
<template>
<AppLayout title="Projects" data-testid="projects_view">
<MainContainer
class="py-3 sm:pt-5 border-b border-default-background-separator flex justify-between items-center">
class="py-3 sm:py-5 border-b border-default-background-separator flex justify-between items-center">
<div class="flex items-center space-x-3 sm:space-x-6">
<PageTitle :icon="FolderIcon" title="Projects"></PageTitle>
<TabBar v-model="activeTab">
<TabBarItem value="active">Active</TabBarItem>
<TabBarItem value="archived">Archived</TabBarItem>
</TabBar>
</div>
<SecondaryButton
v-if="canCreateProjects()"
@@ -143,38 +80,8 @@ const showBillableRate = computed(() => {
:clients="clients"
@submit="createProject"></ProjectCreateModal>
</MainContainer>
<MainContainer>
<div class="flex items-center gap-2 py-1">
<ProjectsFilterDropdown
:filters="tableState.filters"
:clients="clients"
@update:filters="tableState.filters = $event" />
<!-- Active Filters -->
<ProjectStatusFilterBadge
v-if="tableState.filters.status !== 'all'"
data-testid="status-filter-badge"
:value="tableState.filters.status"
@remove="removeStatusFilter"
@update:value="
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge"
:value="tableState.filters.clientIds"
:clients="clients"
@remove="removeClientFilter"
@update:value="tableState.filters.clientIds = $event as string[]" />
</div>
</MainContainer>
<ProjectTable
:show-billable-rate="showBillableRate"
:projects="filteredProjects"
:sort-column="tableState.sortColumn"
:sort-direction="tableState.sortDirection"
@sort="handleSort"></ProjectTable>
:projects="shownProjects"></ProjectTable>
</AppLayout>
</template>

View File

@@ -383,7 +383,7 @@ async function downloadExport(format: ExportFormat) {
@submit="clearSelectionAndState"
@select-all="selectedTimeEntries = [...timeEntries]"
@unselect-all="selectedTimeEntries = []"></TimeEntryMassActionRow>
<div class="w-full relative @container">
<div class="w-full relative">
<div v-for="entry in timeEntries" :key="entry.id">
<TimeEntryRow
:selected="selectedTimeEntries.includes(entry)"

View File

@@ -14,18 +14,13 @@ const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<{
prevent_overlapping_time_entries: boolean;
employees_can_manage_tasks: boolean;
}>({
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
prevent_overlapping_time_entries: false,
employees_can_manage_tasks: false,
});
onMounted(async () => {
form.value.prevent_overlapping_time_entries =
organization.value?.prevent_overlapping_time_entries ?? false;
form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;
});
const mutation = useMutation({
@@ -38,22 +33,22 @@ const mutation = useMutation({
async function submit() {
await mutation.mutateAsync({
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
employees_can_manage_tasks: form.value.employees_can_manage_tasks,
});
}
</script>
<template>
<FormSection>
<template #title>Organization Settings</template>
<template #title>Time Entry Settings</template>
<template #description>
Configure various settings for your organization, including time entry and task
management permissions.
Disallow overlapping time entries for members of this organization. When enabled, users
cannot create new time entries that overlap with their existing ones. This only affects
newly created entries.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4 space-y-4">
<div class="col-span-6 sm:col-span-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
@@ -62,14 +57,6 @@ async function submit() {
for="preventOverlappingTimeEntries"
value="Prevent overlapping time entries (new entries only)" />
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="employeesCanManageTasks"
v-model:checked="form.employees_can_manage_tasks" />
<InputLabel
for="employeesCanManageTasks"
value="Allow Employees to manage tasks" />
</div>
</div>
</div>
</template>

View File

@@ -1,16 +1,15 @@
{
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.5",
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4",
"zod": "^3.23.8"
},
@@ -1095,16 +1094,18 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
@@ -1126,24 +1127,12 @@
"concat-map": "0.0.1"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -1209,24 +1198,11 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -1240,51 +1216,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1349,6 +1280,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4.0"
},
@@ -1359,15 +1291,14 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"license": "MIT",
"peer": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -1408,60 +1339,12 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1479,37 +1362,11 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1632,20 +1489,12 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1655,6 +1504,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -1807,7 +1657,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/punycode": {
"version": "2.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.5",
"description": "Package containing the solidtime api client and type declarations",
"main": "./dist/solidtime-api.umd.cjs",
"module": "./dist/solidtime-api.js",
@@ -29,7 +29,6 @@
"license": "AGPL-3.0",
"dependencies": {
"@zodios/core": "^10.9.6",
"axios": "^1.13.2",
"typescript": "^5.5.4",
"zod": "^3.23.8"
},

View File

@@ -317,7 +317,6 @@ const OrganizationResource = z
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
@@ -333,7 +332,6 @@ const OrganizationUpdateRequest = z
name: z.string().max(255),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
number_format: NumberFormat,
currency_format: CurrencyFormat,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.15",
"version": "0.0.13",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",
@@ -33,9 +33,7 @@
"preview": "vite preview"
},
"files": [
"dist",
"styles.css",
"tailwind.theme.js"
"dist"
],
"keywords": [
"solidtime",

View File

@@ -92,7 +92,7 @@ function updateValue(client: { id: string | null; name: string }) {
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a client..." />
</ComboboxAnchor>
<ComboboxContent>

View File

@@ -6,10 +6,10 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalSeconds?: number;
totalMinutes?: number;
}>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
@@ -25,7 +25,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
</div>
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
</span>
</div>
</template>

View File

@@ -177,23 +177,16 @@ const events = computed(() => {
// Daily totals used in day header
const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
props.timeEntries.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
if (entry.end !== null) {
// Completed entry
durationSeconds = getDayJsInstance()(entry.end).diff(
props.timeEntries
.filter((entry) => entry.end !== null)
.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
const duration = getDayJsInstance()(entry.end!).diff(
getDayJsInstance()(entry.start),
'seconds'
'minutes'
);
} else {
// Running entry - use current time
durationSeconds = currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds');
}
totals[date] = (totals[date] || 0) + durationSeconds;
});
totals[date] = (totals[date] || 0) + duration;
});
return totals;
});
@@ -444,7 +437,7 @@ onUnmounted(() => {
:date="
getDayJsInstance()(arg.date.toISOString()).utc().tz(getUserTimezone(), true)
"
:total-seconds="
:total-minutes="
dailyTotals[
getDayJsInstance()(arg.date)
.utc()
@@ -548,7 +541,7 @@ onUnmounted(() => {
}
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
background-color: var(--color-bg-secondary);
background-color: var(--color-accent-default);
}
.fullcalendar :deep(.fc-day-today) {
@@ -712,41 +705,28 @@ onUnmounted(() => {
/* Activity status plugin styles */
.fullcalendar :deep(.activity-status-box) {
position: absolute;
width: 10px;
left: 0px;
z-index: 10;
cursor: default;
}
.fullcalendar :deep(.activity-status-box::before) {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 5px;
transition: opacity 0.2s ease;
}
.fullcalendar :deep(.activity-status-box.idle::before) {
background-color: rgba(156, 163, 175, 0.1);
.fullcalendar :deep(.activity-status-box.idle) {
background-color: rgba(156, 163, 175, 0.1) !important;
}
.fullcalendar :deep(.activity-status-box.idle):hover::before {
background-color: rgba(156, 163, 175, 0.5);
.fullcalendar :deep(.activity-status-box.idle):hover {
background-color: rgba(156, 163, 175, 0.5) !important;
}
.fullcalendar :deep(.activity-status-box.active::before) {
background-color: rgba(34, 197, 94, 0.3);
.fullcalendar :deep(.activity-status-box.active) {
background-color: rgba(34, 197, 94, 0.3) !important;
}
.fullcalendar :deep(.activity-status-box.active):hover::before {
background-color: rgba(34, 197, 94, 1);
.fullcalendar :deep(.activity-status-box.active):hover {
background-color: rgba(34, 197, 94, 1) !important;
}
/* Add left margin to events only on days with activity status data */
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
margin-left: 8px !important;
margin-left: 15px !important;
}
.fullcalendar :deep(.fc-timegrid-event) {

View File

@@ -1,70 +1,42 @@
import { createPlugin, type PluginDef } from '@fullcalendar/core';
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
export interface WindowActivityInPeriod {
appName: string;
url: string | null;
count: number;
icon?: string | null;
}
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
export interface ActivityPeriod {
start: string;
end: string;
isIdle: boolean;
windowActivities?: WindowActivityInPeriod[];
}
export interface ActivityStatusPluginOptions {
activityPeriods?: ActivityPeriod[];
}
// Tooltip state management - single instance per module
let tooltipInstance: HTMLElement | null = null;
let cleanupAutoUpdate: (() => void) | null = null;
/**
* Creates and manages a tooltip element for activity status boxes
*/
function getOrCreateTooltip(): HTMLElement {
if (!tooltipInstance) {
tooltipInstance = document.createElement('div');
tooltipInstance.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltipInstance.style.position = 'fixed';
tooltipInstance.style.pointerEvents = 'none';
tooltipInstance.style.opacity = '0';
tooltipInstance.style.whiteSpace = 'nowrap';
tooltipInstance.style.transform = 'scale(0.95)';
tooltipInstance.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltipInstance);
}
return tooltipInstance;
function createTooltip(): HTMLElement {
const tooltip = document.createElement('div');
tooltip.className =
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
tooltip.style.position = 'fixed';
tooltip.style.pointerEvents = 'none';
tooltip.style.opacity = '0';
tooltip.style.whiteSpace = 'nowrap';
tooltip.style.transform = 'scale(0.95)';
tooltip.style.transition = 'opacity 150ms, transform 150ms';
document.body.appendChild(tooltip);
return tooltip;
}
/**
* Shows tooltip for an activity status box using Floating UI's autoUpdate
* Shows tooltip for an activity status box
*/
function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | HTMLElement) {
// Clear previous content
tooltip.innerHTML = '';
if (typeof content === 'string') {
tooltip.textContent = content;
} else {
tooltip.appendChild(content);
}
function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
tooltip.textContent = text;
tooltip.style.opacity = '1';
tooltip.style.transform = 'scale(1)';
// Clean up previous autoUpdate if it exists
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
}
// Use autoUpdate to automatically update position
cleanupAutoUpdate = autoUpdate(box, tooltip, () => {
const updatePosition = () => {
computePosition(box, tooltip, {
placement: 'right',
middleware: [offset(8), flip(), shift({ padding: 5 })],
@@ -72,124 +44,17 @@ function showTooltip(box: HTMLElement, tooltip: HTMLElement, content: string | H
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
});
});
};
updatePosition();
}
/**
* Hides the tooltip immediately
* Hides the tooltip
*/
function hideTooltip(tooltip: HTMLElement) {
tooltip.style.opacity = '0';
tooltip.style.transform = 'scale(0.95)';
// Clean up autoUpdate when tooltip is hidden
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* Formats duration in minutes to human readable format
*/
function formatDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
/**
* Creates tooltip content for an activity period
*/
function createTooltipContent(
status: string,
durationText: string,
windowActivities?: WindowActivityInPeriod[]
): string | HTMLElement {
if (!windowActivities || windowActivities.length === 0) {
return `${status} (${durationText})`;
}
const container = document.createElement('div');
container.style.maxWidth = '300px';
// Header with status and duration
const header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '8px';
header.textContent = `${status} (${durationText})`;
container.appendChild(header);
// Window activities list
const totalActivities = windowActivities.reduce((sum, act) => sum + act.count, 0);
// Show top 5 activities
const topActivities = windowActivities.slice(0, 5);
topActivities.forEach((activity) => {
const activityDiv = document.createElement('div');
activityDiv.style.marginTop = '4px';
activityDiv.style.fontSize = '11px';
activityDiv.style.opacity = '0.9';
activityDiv.style.display = 'flex';
activityDiv.style.alignItems = 'center';
activityDiv.style.gap = '6px';
// Add icon if available
if (activity.icon) {
const icon = document.createElement('img');
icon.src = activity.icon;
icon.alt = activity.appName;
icon.style.width = '16px';
icon.style.height = '16px';
icon.style.borderRadius = '2px';
icon.style.flexShrink = '0';
activityDiv.appendChild(icon);
} else {
// Placeholder for no icon
const placeholder = document.createElement('div');
placeholder.style.width = '16px';
placeholder.style.height = '16px';
placeholder.style.borderRadius = '2px';
placeholder.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
placeholder.style.display = 'flex';
placeholder.style.alignItems = 'center';
placeholder.style.justifyContent = 'center';
placeholder.style.fontSize = '8px';
placeholder.style.flexShrink = '0';
placeholder.textContent = activity.appName.charAt(0).toUpperCase();
activityDiv.appendChild(placeholder);
}
const textSpan = document.createElement('span');
textSpan.style.flex = '1';
textSpan.style.overflow = 'hidden';
textSpan.style.textOverflow = 'ellipsis';
textSpan.style.whiteSpace = 'nowrap';
const percentage = ((activity.count / totalActivities) * 100).toFixed(0);
const activityText = activity.url
? `${activity.appName} - ${activity.url}`
: activity.appName;
textSpan.textContent = `${percentage}% ${activityText}`;
activityDiv.appendChild(textSpan);
container.appendChild(activityDiv);
});
// Show "and X more" if there are more activities
if (windowActivities.length > 5) {
const moreDiv = document.createElement('div');
moreDiv.style.marginTop = '4px';
moreDiv.style.fontSize = '11px';
moreDiv.style.opacity = '0.7';
moreDiv.style.fontStyle = 'italic';
moreDiv.textContent = `...and ${windowActivities.length - 5} more`;
container.appendChild(moreDiv);
}
return container;
}
/**
@@ -201,32 +66,49 @@ export function renderActivityStatusBoxes(
) {
if (!calendarEl) return;
// Clean up existing activity boxes
// Clean up existing activity boxes and markers first
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
existingBoxes.forEach((box) => box.remove());
// Clean up existing tooltips
const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
existingTooltips.forEach((tooltip) => tooltip.remove());
// Remove has-activity-status class from all lanes
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
if (!timeGrid) return;
if (!timeGrid) {
console.log('No timegrid found');
return;
}
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
if (lanes.length === 0) return;
if (lanes.length === 0) {
console.log('No lanes found');
return;
}
// Get or reuse the single tooltip instance
const tooltip = getOrCreateTooltip();
console.log(
'Rendering activity status boxes, lanes:',
lanes.length,
'periods:',
activityPeriods.length
);
// Get slot duration from calendar (fallback to 15 minutes)
const slotDurationMinutes = getSlotDuration(calendarEl);
// Create a single tooltip instance to be reused
const tooltip = createTooltip();
lanes.forEach((lane: Element) => {
lanes.forEach((lane: Element, dayIndex: number) => {
// Get the date for this lane from the data attribute
const laneEl = lane as HTMLElement;
const dateStr = laneEl.getAttribute('data-date');
if (!dateStr) return;
if (!dateStr) {
console.log('No date attribute found for lane', dayIndex);
return;
}
const laneDate = new Date(dateStr);
const laneDateStart = new Date(laneDate);
@@ -245,46 +127,47 @@ export function renderActivityStatusBoxes(
return;
}
// Calculate actual start and end times for this day
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
// Calculate the position and height of the activity box
// Calculate the position and height of the idle box
const { top, height } = calculateBoxPosition(
calendarEl,
actualStart,
actualEnd,
slotDurationMinutes
periodStart > laneDateStart ? periodStart : laneDateStart,
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
);
if (height <= 0) return;
hasActivityStatusForThisDay = true;
// Calculate duration in minutes
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
const durationText = formatDuration(durationMinutes);
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
// Create and append the activity status box
const box = document.createElement('div');
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
box.style.position = 'absolute';
box.style.top = `${top}px`;
box.style.height = `${height}px`;
box.style.width = '8px';
box.style.left = '4px';
box.style.right = '4px';
box.style.zIndex = '10';
box.style.cursor = 'default';
// Store tooltip content generator in data attribute for event delegation
const tooltipContent = createTooltipContent(
status,
durationText,
period.windowActivities
);
// Calculate duration in minutes
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
const durationMs = actualEnd.getTime() - actualStart.getTime();
const durationMinutes = Math.round(durationMs / 60000);
// Format duration
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
// Add tooltip text based on status
const status = period.isIdle ? 'Idling' : 'Active';
const tooltipText = `${status} (${durationText})`;
// Add hover event listeners for tooltip
box.addEventListener('mouseenter', () => {
showTooltip(box, tooltip, tooltipContent);
showTooltip(box, tooltip, tooltipText);
});
box.addEventListener('mouseleave', () => {
@@ -295,6 +178,8 @@ export function renderActivityStatusBoxes(
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
if (laneFrame) {
laneFrame.appendChild(box);
} else {
console.log('No lane frame found');
}
});
@@ -305,43 +190,18 @@ export function renderActivityStatusBoxes(
});
}
/**
* Gets the slot duration from the calendar configuration
*/
function getSlotDuration(calendarEl: HTMLElement): number {
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length < 2) return 15; // Default to 15 minutes
// Try to calculate from the time difference between slots
const firstSlot = slotsEl[0] as HTMLElement;
const secondSlot = slotsEl[1] as HTMLElement;
const firstTime = firstSlot.getAttribute('data-time');
const secondTime = secondSlot.getAttribute('data-time');
if (firstTime && secondTime) {
const [h1, m1] = firstTime.split(':').map(Number);
const [h2, m2] = secondTime.split(':').map(Number);
const diff = h2 * 60 + m2 - (h1 * 60 + m1);
if (diff > 0) return diff;
}
// Fallback to 15 minutes
return 15;
}
/**
* Calculates the pixel position and height for an activity status box
*/
function calculateBoxPosition(
calendarEl: HTMLElement,
startTime: Date,
endTime: Date,
slotDurationMinutes: number
endTime: Date
): { top: number; height: number } {
// Get the slot duration and slot height
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
if (slotsEl.length === 0) {
console.log('No slots found');
return { top: 0, height: 0 };
}
@@ -349,6 +209,8 @@ function calculateBoxPosition(
const firstSlot = slotsEl[0] as HTMLElement;
const slotHeight = firstSlot.offsetHeight;
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
const slotDurationMinutes = 15;
const pixelsPerMinute = slotHeight / slotDurationMinutes;
// Calculate start position (minutes from midnight)
@@ -362,20 +224,6 @@ function calculateBoxPosition(
return { top, height };
}
/**
* Cleanup function to remove tooltip from DOM
*/
export function cleanupActivityStatusPlugin() {
if (tooltipInstance) {
tooltipInstance.remove();
tooltipInstance = null;
}
if (cleanupAutoUpdate) {
cleanupAutoUpdate();
cleanupAutoUpdate = null;
}
}
/**
* FullCalendar plugin to display idle/active status boxes in the time grid
*/

View File

@@ -1,20 +0,0 @@
<script setup lang="ts"></script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M2 5h20" />
<path d="M6 12h12" />
<path d="M9 19h6" />
</svg>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import Button from '../Buttons/Button.vue';
import { RangeCalendar } from '../range-calendar';
import { RangeCalendar } from '@/Components/ui/range-calendar';
import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
import { watch } from 'vue';
const props = withDefaults(
@@ -52,7 +52,7 @@ watch(open, (value) => {
</PopoverTrigger>
<PopoverContent
:align="align"
class="rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-secondary"
class="rounded-lg overflow-hidden relative border border-card-border overflow-none shadow-dropdown bg-card-background"
@open-auto-focus="handleAutofocus"
@click="onContentClick">
<slot name="content" />

View File

@@ -145,7 +145,7 @@ const highlightedItem = computed(() => {
<input
ref="searchInput"
:value="searchValue"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
:placeholder="searchPlaceholder"
@input="updateSearchValue"
@keydown.up.prevent="moveHighlightUp"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script>
<template>
<div class="px-3 sm:px-4 lg:px-6 mx-auto">
<div class="px-3 sm:px-4 lg:px-8 3xl:px-12 mx-auto">
<slot></slot>
</div>
</template>

View File

@@ -171,7 +171,7 @@ const showCreateTagModal = ref(false);
ref="searchInput"
:value="searchValue"
data-testid="tag_dropdown_search"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a Tag..."
@input="updateSearchValue"
@keydown.esc.prevent="open = false"

View File

@@ -93,7 +93,7 @@ function onSelectChange(checked: boolean) {
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="@xl:flex py-2 items-center min-w-0 justify-between group">
<div class="@sm:flex py-2 items-center min-w-0 justify-between group">
<div class="flex space-x-3 items-center min-w-0">
<Checkbox
:checked="

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import type {
CreateClientBody,
CreateProjectBody,
@@ -38,6 +38,8 @@ const props = defineProps<{
canCreateProject: boolean;
}>();
const maxVisibleGroups = ref(7); // Start with 10 day groups, then show all
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of props.timeEntries) {
@@ -135,11 +137,43 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
);
});
}
const visibleGroupedEntries = computed(() => {
const allGroups = Object.entries(groupedTimeEntries.value);
return Object.fromEntries(allGroups.slice(0, maxVisibleGroups.value));
});
const totalGroups = computed(() => Object.keys(groupedTimeEntries.value).length);
function startProgressiveLoading() {
const loadMoreGroups = () => {
if (maxVisibleGroups.value < totalGroups.value) {
maxVisibleGroups.value = Math.min(maxVisibleGroups.value + 5, totalGroups.value);
if (maxVisibleGroups.value < totalGroups.value) {
requestIdleCallback(loadMoreGroups);
}
}
};
requestIdleCallback(loadMoreGroups);
}
// Watch for changes to totalGroups and adjust maxVisibleGroups accordingly
watch(totalGroups, (newTotal, oldTotal) => {
if (newTotal !== oldTotal) {
maxVisibleGroups.value = newTotal;
}
});
onMounted(() => {
startProgressiveLoading();
});
</script>
<template>
<div class="@container">
<div v-for="(value, key) in groupedTimeEntries" :key="key">
<div v-for="(value, key) in visibleGroupedEntries" :key="key">
<TimeEntryRowHeading
:date="String(key)"
:duration="sumDuration(value)"

View File

@@ -63,7 +63,7 @@ const showMassUpdateModal = ref(false);
:class="
twMerge(
props.class,
'text-sm py-1.5 font-medium flex border-b border-border-primary items-center space-x-3'
'text-sm py-1.5 font-medium bg-secondary flex items-center space-x-3'
)
">
<Checkbox

View File

@@ -112,7 +112,7 @@ async function handleDeleteTimeEntry() {
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="@xl:flex py-2 min-w-0 items-center justify-between group">
<div class="@sm:flex py-2 min-w-0 items-center justify-between group">
<div class="flex items-center min-w-0">
<Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div>

View File

@@ -8,7 +8,6 @@ import {
import Checkbox from '../Input/Checkbox.vue';
import { inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
import { CalendarIcon } from '@heroicons/vue/20/solid';
const organization = inject<ComputedRef<Organization>>('organization');
@@ -33,24 +32,32 @@ function selectUnselectAll(value: boolean) {
<template>
<div
class="bg-background dark:bg-quaternary border-b border-border-primary py-1 text-xs @sm:text-sm">
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
<MainContainer>
<div class="flex group justify-between items-center">
<div class="flex items-center space-x-2">
<div class="w-5">
<CalendarIcon
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block">
</CalendarIcon>
<svg
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7zm-5-9a1 1 0 0 1 1 1v1h2a2 2 0 0 1 2 2v3H3V7a2 2 0 0 1 2-2h2V4a1 1 0 0 1 2 0v1h6V4a1 1 0 0 1 1-1" />
</g>
</svg>
<Checkbox
:checked="checked"
class="group-hover:block hidden"
@update:checked="selectUnselectAll"></Checkbox>
</div>
<span class="font-medium text-text-secondary">
<span class="font-medium text-text-primary">
{{ formatWeekday(date) }}
</span>
<span class="text-text-tertiary">
<span class="font-medium text-text-secondary">
{{ formatDate(date, organization?.date_format) }}
</span>
</div>

View File

@@ -207,7 +207,7 @@ useSelectEvents(
<template>
<div class="flex items-center relative @container" data-testid="dashboard_timer">
<div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-secondary border-card-border border transition shadow-card">
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
<div class="flex flex-1 items-center pr-6 relative">
<input
ref="currentTimeEntryDescriptionInput"

View File

@@ -543,7 +543,7 @@ const showCreateProject = ref(false);
ref="searchInput"
:value="searchValue"
data-testid="client_dropdown_search"
class="bg-card-background border-0 placeholder-text-tertiary text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project or task..."
@input="updateSearchValue"
@keydown.enter.prevent="addClientIfNoneExists"

View File

@@ -154,7 +154,7 @@ function closeAndFocusInput() {
v-model="currentTime"
placeholder="00:00:00"
data-testid="time_entry_time"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-secondary border-none placeholder-text-tertiary focus:ring-0 transition"
class="w-[110px] lg:w-[130px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base lg:text-lg font-semibold bg-card-background border-none placeholder-muted focus:ring-0 transition"
type="text"
@focusin="openModalOnTab"
@click="openModalOnClick"

View File

@@ -1,109 +0,0 @@
<script setup lang="ts">
import SecondaryButton from './Buttons/SecondaryButton.vue';
import DialogModal from './DialogModal.vue';
import PrimaryButton from './Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import { getUserTimezone } from './utils/settings';
import { getDayJsInstance } from './utils/time';
import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false });
const emit = defineEmits<{
update: [timezone: string];
cancel: [];
}>();
defineProps<{
saving?: boolean;
}>();
const timezone = ref('');
const userTimezone = ref('');
const shouldShow = ref(false);
const hideTimezoneMismatchModal = useSessionStorage<boolean>('hide-timezone-mismatch-modal', false);
/**
* Check if timezone mismatch exists and should be shown
*/
function checkTimezoneMismatch(): boolean {
timezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
userTimezone.value = getUserTimezone();
const now = getDayJsInstance()();
const hasMismatch =
now.tz(timezone.value).format() !== now.tz(userTimezone.value).format() &&
!hideTimezoneMismatchModal.value;
shouldShow.value = hasMismatch;
return hasMismatch;
}
onMounted(() => {
checkTimezoneMismatch();
if (shouldShow.value) {
show.value = true;
}
});
function submit() {
emit('update', timezone.value);
}
function cancel() {
show.value = false;
hideTimezoneMismatchModal.value = true;
emit('cancel');
}
// Expose methods for parent component
defineExpose({
checkTimezoneMismatch,
currentTimezone: timezone,
userTimezone,
});
</script>
<template>
<DialogModal closeable :show="show && shouldShow" @close="cancel">
<template #title>
<div class="flex justify-center">
<span> Timezone mismatch detected </span>
</div>
</template>
<template #content>
<div class="flex items-center space-x-4">
<div class="col-span-6 sm:col-span-4 flex-1 space-y-2">
<p>
The timezone of your device does not match the timezone in your user
settings. <br />
<strong
>We highly recommend that you update your timezone settings to your
current timezone.</strong
>
</p>
<p>
Want to change your timezone setting from
<strong>{{ userTimezone }}</strong> to <strong>{{ timezone }}</strong
>.
</p>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="cancel"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Update timezone
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -37,12 +37,7 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue';
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion/index';
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './popover/index';
import { RangeCalendar } from './range-calendar/index';
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export {
@@ -74,19 +69,8 @@ export {
FullCalendarEventContent,
FullCalendarDayHeader,
TimeEntryCalendar,
DateRangePicker,
TimezoneMismatchModal,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Popover,
PopoverContent,
PopoverTrigger,
PopoverAnchor,
RangeCalendar,
};

View File

@@ -33,14 +33,14 @@
--theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-primary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
@@ -51,7 +51,7 @@
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: transparent;
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
@@ -63,7 +63,7 @@
:root.light {
--color-bg-primary: #ffffff;
--color-bg-secondary: #fcfcfc;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-background: #f5f5f5;
@@ -86,8 +86,8 @@
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-row-background: var(--theme-color-primary);
--theme-color-row-heading-background: var(--theme-color-primary);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
@@ -98,7 +98,7 @@
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #ffffff;
--theme-color-input-background: transparent;
--theme-color-input-background: var(--color-bg-primary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
@@ -142,8 +142,28 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: thin;
scrollbar-color: var(--color-bg-tertiary) transparent;
}
/* width */
::-webkit-scrollbar {
width: 5px;
}
/* Track */
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 2px;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
[x-cloak] {
@@ -163,13 +183,13 @@ body {
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
@@ -187,16 +207,16 @@ body {
--foreground: var(--color-text-primary);
--card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary);
--popover: var(--color-bg-tertiary);
--popover: var(--theme-color-card-background);
--popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary);
--primary-foreground: var(--theme-color-button-primary-text);
--secondary: var(--color-bg-secondary);
--secondary-foreground: var(--color-text-primary);
--muted: var(--color-bg-tertiary);
--muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary);
--accent: var(--theme-color-button-primary-background);
--accent-foreground: var(--theme-color-button-primary-text);
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);

View File

@@ -38,7 +38,7 @@ export const solidtimeTheme = {
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'var(--secondary)',
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
tertiary: 'var(--color-bg-tertiary)',
@@ -60,8 +60,8 @@ export const solidtimeTheme = {
'card-border': 'var(--theme-color-card-border)',
'card-border-active': 'var(--theme-color-card-border-active)',
muted: {
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)',
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
'tab-background': 'var(--theme-color-tab-background)',
'tab-background-active': 'var(--theme-color-tab-background-active)',
@@ -90,8 +90,8 @@ export const solidtimeTheme = {
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)',
DEFAULT: 'var(--color-accent-default)',
foreground: 'var(--color-accent-foreground)',
},
'button-primary-background': 'var(--theme-color-button-primary-background)',
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',

View File

@@ -9,16 +9,10 @@ import type {
} from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { useQueryClient } from '@tanstack/vue-query';
export const useProjectsStore = defineStore('projects', () => {
const projectResponse = ref<ProjectResponse | null>(null);
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
function invalidateProjectsQuery() {
queryClient.invalidateQueries({ queryKey: ['projects'] });
}
async function fetchProjects() {
const organization = getCurrentOrganizationId();
if (organization) {
@@ -54,7 +48,6 @@ export const useProjectsStore = defineStore('projects', () => {
);
await fetchProjects();
invalidateProjectsQuery();
return response['data'];
}
}
@@ -74,7 +67,6 @@ export const useProjectsStore = defineStore('projects', () => {
'Failed to delete project'
);
await fetchProjects();
invalidateProjectsQuery();
}
}
@@ -93,7 +85,6 @@ export const useProjectsStore = defineStore('projects', () => {
'Failed to update project'
);
await fetchProjects();
invalidateProjectsQuery();
}
}

View File

@@ -1,34 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { api } from '@/packages/api/src';
import { getCurrentOrganizationId } from '@/utils/useUser';
import type { Project } from '@/packages/api/src';
import { computed } from 'vue';
export function useProjectsQuery() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const organizationId = getCurrentOrganizationId();
if (!organizationId) throw new Error('No organization');
return api.getProjects({
params: { organization: organizationId },
queries: { archived: 'all' },
});
},
enabled: () => !!getCurrentOrganizationId(),
});
const projects = computed<Project[]>(() => query.data.value?.data ?? []);
const invalidateProjects = () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
return {
...query,
projects,
invalidateProjects,
};
}

View File

@@ -14,7 +14,6 @@ export default {
'./resources/views/**/*.blade.php',
'./resources/js/**/*.vue',
'./resources/js/**/*.ts',
'!./resources/js/**/node_modules',
],
theme: {
extend: {

View File

@@ -299,7 +299,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create:all',
'tasks:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create([
@@ -324,7 +324,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create:all',
'tasks:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create();
@@ -352,7 +352,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create:all',
'tasks:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -376,7 +376,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create:all',
'tasks:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -408,7 +408,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:create:all',
'tasks:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -465,7 +465,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$name = 'Task 1';
@@ -493,7 +493,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$otherProject = Project::factory()->forOrganization($data->organization)->create();
@@ -523,7 +523,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -547,7 +547,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
$now = Carbon::now();
$this->travelTo($now);
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -570,7 +570,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$task = Task::factory()->forOrganization($data->organization)->isDone()->create();
Passport::actingAs($data->user);
@@ -593,7 +593,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -621,7 +621,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:update:all',
'tasks:update',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -650,7 +650,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete:all',
'tasks:delete',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
@@ -669,7 +669,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete:all',
'tasks:delete',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forMember($data->member)->forTask($task)->forOrganization($data->organization)->create();
@@ -707,10 +707,10 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete:all',
'tasks:delete',
]);
$otherData = $this->createUserWithPermission([
'tasks:delete:all',
'tasks:delete',
]);
$task = Task::factory()->forOrganization($otherData->organization)->create();
Passport::actingAs($data->user);
@@ -724,274 +724,4 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
'id' => $task->getKey(),
]);
}
public function test_store_endpoint_allows_employee_to_create_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_allows_employee_to_create_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertDatabaseHas(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_fails_for_employee_creating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Task::class, [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
}
public function test_store_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [
'name' => 'Employee Task',
'project_id' => $project->getKey(),
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Task::class, [
'name' => 'Employee Task',
]);
}
public function test_update_endpoint_allows_employee_to_update_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => 'Updated by Employee',
]);
}
public function test_update_endpoint_allows_employee_to_update_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => 'Updated by Employee',
]);
}
public function test_update_endpoint_fails_for_employee_updating_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$originalName = $task->name;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => $originalName,
]);
}
public function test_update_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
$originalName = $task->name;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [
'name' => 'Updated by Employee',
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
'name' => $originalName,
]);
}
public function test_delete_endpoint_allows_employee_to_delete_task_in_public_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_allows_employee_to_delete_task_in_accessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_for_employee_deleting_task_in_inaccessible_private_project_when_employees_can_manage_tasks_is_enabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = true;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_for_employee_when_employees_can_manage_tasks_is_disabled(): void
{
// Arrange
$data = $this->createUserWithRole(\App\Enums\Role::Employee);
$data->organization->employees_can_manage_tasks = false;
$data->organization->save();
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
}

View File

@@ -436,52 +436,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_can_round_up_but_does_not_round_up_if_already_on_border(): void
{
// Arrange
$this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04'));
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'),
'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:06:00'),
]);
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'),
'end' => null,
]);
$this->actAsOrganizationWithSubscription();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
'rounding_type' => TimeEntryRoundingType::Up,
'rounding_minutes' => 6,
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('meta')
->where('meta.total', 2)
->count('data', 2)
->where('data.0.id', $timeEntry1->getKey())
->where('data.0.start', '2020-01-01T00:00:00Z')
->where('data.0.end', '2020-01-01T00:06:00Z')
->where('data.1.id', $timeEntry2->getKey())
->where('data.1.start', '2020-01-01T00:00:00Z')
->where('data.1.end', '2020-01-01T00:18:00Z')
);
}
public function test_index_endpoint_ignores_rounding_if_organization_has_no_premium_features(): void
{
// Arrange
@@ -1968,7 +1922,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->withTags($data->organization)->make();
@@ -1996,7 +1949,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2026,7 +1978,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
$timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2056,7 +2007,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
@@ -2087,7 +2037,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
@@ -2104,47 +2053,11 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(422);
}
public function test_store_endpoint_fails_if_employee_tries_to_create_time_entry_for_private_project_without_access(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
// Create a private project that the employee is not a member of
$privateProject = Project::factory()->forOrganization($data->organization)->create([
'is_public' => false,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'description' => 'Test time entry',
'billable' => false,
'start' => now()->toIso8601ZuluString(),
'end' => now()->addHour()->toIso8601ZuluString(),
'member_id' => $data->member->getKey(),
'project_id' => $privateProject->getKey(),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['project_id']);
// Verify the time entry was NOT created in the database
$this->assertDatabaseMissing(TimeEntry::class, [
'project_id' => $privateProject->getKey(),
'member_id' => $data->member->getKey(),
]);
}
public function test_store_endpoints_sets_billable_rate(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->withTask($data->organization)->forOrganization($data->organization)->make();
$project = Project::factory()->forOrganization($data->organization)->billable()->create();
@@ -2175,7 +2088,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
@@ -2201,7 +2113,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
@@ -2232,7 +2143,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
@@ -2292,7 +2202,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
@@ -2327,39 +2236,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
});
}
public function test_update_endpoint_fails_if_employee_tries_to_update_time_entry_to_private_project_without_access(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
// Create a time entry for the employee
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
// Create a private project that the employee is not a member of
$privateProject = Project::factory()->forOrganization($data->organization)->create([
'is_public' => false,
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'project_id' => $privateProject->getKey(),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['project_id']);
// Verify the time entry was NOT updated in the database
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'project_id' => $privateProject->getKey(),
]);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_own_time_entries(): void
{
// Arrange
@@ -2388,11 +2264,9 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherUser = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forMember($otherUser->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
@@ -2416,7 +2290,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$user = User::factory()->create();
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create();
@@ -2442,7 +2315,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2472,7 +2344,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make();
@@ -2502,7 +2373,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
@@ -2531,7 +2401,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
@@ -2563,7 +2432,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make();
@@ -2591,7 +2459,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make();
@@ -2709,7 +2576,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forOrganization($data->organization)->forProject($project)->create();
@@ -2744,7 +2610,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$oldProject = Project::factory()->forOrganization($data->organization)->create();
$oldTask = Task::factory()->forOrganization($data->organization)->forProject($oldProject)->create();
@@ -3164,53 +3029,11 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_update_multiple_endpoint_fails_if_employee_tries_to_update_time_entries_to_private_project_without_access(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
// Create time entries for the employee
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
// Create a private project that the employee is not a member of
$privateProject = Project::factory()->forOrganization($data->organization)->create([
'is_public' => false,
]);
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [$timeEntry1->getKey(), $timeEntry2->getKey()],
'changes' => [
'project_id' => $privateProject->getKey(),
],
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['changes.project_id']);
// Verify the time entries were NOT updated in the database
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry1->getKey(),
'project_id' => $privateProject->getKey(),
]);
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $timeEntry2->getKey(),
'project_id' => $privateProject->getKey(),
]);
}
public function test_update_multiple_remove_task_from_time_entries_only_if_project_is_set_to_a_new_value_without_setting_a_new_task(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$project1 = Project::factory()->forOrganization($data->organization)->create();
$project2 = Project::factory()->forOrganization($data->organization)->create();
@@ -3258,7 +3081,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherData = $this->createUserWithPermission();
$otherUser = User::factory()->create();
@@ -3316,7 +3138,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherData = $this->createUserWithPermission();
$otherUser = User::factory()->create();
@@ -3396,7 +3217,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create([
'description' => '',
@@ -3578,7 +3398,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3613,7 +3432,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3648,7 +3466,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3683,7 +3500,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3718,7 +3534,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3755,7 +3570,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->travelTo($now);
$data = $this->createUserWithPermission([
'time-entries:create:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -3783,7 +3597,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
@@ -4007,7 +3820,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\TimeEntryResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Config;
@@ -52,149 +50,4 @@ class TimeEntryResourceTest extends FilamentTestCase
// Assert
$response->assertSuccessful();
}
public function test_can_see_create_page_of_time_entry(): void
{
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class);
// Assert
$response->assertSuccessful();
}
public function test_can_create_time_entry(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class)
->fillForm([
'description' => 'Test time entry',
'billable' => true,
'start' => '2024-01-01 08:00:00',
'end' => '2024-01-01 10:00:00',
'member_id' => $member->getKey(),
])
->call('create')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry = TimeEntry::where('description', 'Test time entry')->first();
$this->assertNotNull($timeEntry);
$this->assertSame($member->getKey(), $timeEntry->member_id);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
$this->assertTrue($timeEntry->billable);
}
public function test_can_create_time_entry_and_derives_user_and_organization_from_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$otherUser = User::factory()->create();
$otherOrganization = Organization::factory()->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\CreateTimeEntry::class)
->fillForm([
'description' => 'Derived fields test',
'billable' => false,
'start' => '2024-03-01 09:00:00',
'end' => '2024-03-01 11:00:00',
'member_id' => $member->getKey(),
'user_id' => $otherUser->getKey(),
'organization_id' => $otherOrganization->getKey(),
])
->call('create')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry = TimeEntry::where('description', 'Derived fields test')->first();
$this->assertNotNull($timeEntry);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
}
public function test_can_update_time_entry(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$timeEntry = TimeEntry::factory()->forMember($member)->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\EditTimeEntry::class, ['record' => $timeEntry->getKey()])
->fillForm([
'description' => 'Updated description',
'billable' => true,
'start' => '2024-02-01 08:00:00',
'end' => '2024-02-01 12:00:00',
'member_id' => $member->getKey(),
])
->call('save')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry->refresh();
$this->assertSame('Updated description', $timeEntry->description);
$this->assertTrue($timeEntry->billable);
$this->assertSame($user->getKey(), $timeEntry->user_id);
$this->assertSame($organization->getKey(), $timeEntry->organization_id);
}
public function test_update_time_entry_derives_user_and_organization_from_new_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$user = User::factory()->create();
$member = Member::factory()
->forOrganization($organization)
->forUser($user)
->create();
$timeEntry = TimeEntry::factory()->create();
$newOrganization = Organization::factory()->create();
$newUser = User::factory()->create();
$newMember = Member::factory()
->forOrganization($newOrganization)
->forUser($newUser)
->create();
// Act
$response = Livewire::test(TimeEntryResource\Pages\EditTimeEntry::class, ['record' => $timeEntry->getKey()])
->fillForm([
'description' => 'Reassigned entry',
'billable' => false,
'start' => '2024-02-01 08:00:00',
'end' => '2024-02-01 12:00:00',
'member_id' => $newMember->getKey(),
])
->call('save')
->assertHasNoFormErrors();
// Assert
$response->assertSuccessful();
$timeEntry->refresh();
$this->assertSame($newMember->getKey(), $timeEntry->member_id);
$this->assertSame($newUser->getKey(), $timeEntry->user_id);
$this->assertSame($newOrganization->getKey(), $timeEntry->organization_id);
}
}

Some files were not shown because too many files have changed in this diff Show More