Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
93d6a86
Show clients that are assigned to the employee, closes #893
agross Oct 16, 2025
1e985b7
move Client visibleByEmployee logic from controller to model
Onatcer Oct 21, 2025
9a1dd48
Extend description to 5000 chars, closes #914
agross Oct 16, 2025
55d12aa
add discard option for running timer
Onatcer Oct 16, 2025
c359259
fix TimeRangeSelector dropdown behaviour when clicking after other in…
Onatcer Oct 21, 2025
f8f708a
add set end time functionality to timetracker component
Onatcer Oct 21, 2025
9fe0ea5
add support for HH:mm:ss format for input time fields
Onatcer Oct 22, 2025
3b41de7
remove project default listener in timeentry edit modal
Onatcer Oct 22, 2025
df00200
load current member time entries in calendar, to be consistent with t…
Onatcer Oct 22, 2025
0f21fab
Spread self-hosting update and telemetry requests over the day
korridor Nov 2, 2025
3417b60
only run self-hosting update and telemetry scheduler when app_key is set
Onatcer Nov 4, 2025
6eca0c2
fix archived_at timestamp of client in exporter
Onatcer Nov 11, 2025
b7dafb0
bump api and ui package versions
Onatcer Nov 4, 2025
8a2f35d
fix package build error dependencies
Onatcer Nov 4, 2025
8ba04ec
move currency and cancreateproject permission to props to decouple Ti…
Onatcer Nov 4, 2025
0648437
design fixes, improve component encapsulation
Onatcer Nov 6, 2025
9b9371e
move button component to ui package
Onatcer Nov 12, 2025
09fb5aa
make sure that timepicker and calendar set seconds to 0 on update, fi…
Onatcer Nov 12, 2025
947550d
move css variables and tailwind theme config into ui package
Onatcer Nov 12, 2025
878ac4a
add tooltip component
Onatcer Nov 12, 2025
306a081
prevent seconds update on timepicker when nothing else changes
Onatcer Nov 12, 2025
95ab169
make sure that CreateTimeEntry modal always starts with times that ha…
Onatcer Nov 12, 2025
8a2c260
use container queries for time entry table
Onatcer Nov 13, 2025
8d3ee58
improve initial mount performance for groupedtimeentrytable by stream…
Onatcer Nov 13, 2025
33ac994
add activity status plugin to calendar
Onatcer Nov 17, 2025
1bd2c28
add tooltips to idlestatus indicators
Onatcer Nov 18, 2025
249ab67
improve idle indicator colors, fix typescript issues
Onatcer Nov 19, 2025
5b491b0
add support for currently running time entry
Onatcer Nov 19, 2025
f955ab3
fix display problems caused by minimum height of calendar events
Onatcer Nov 19, 2025
022caf5
bump solidtime ui package version to 0.0.13
Onatcer Nov 19, 2025
bacd6f4
include the currently running time entry in the calendar header
Onatcer Nov 20, 2025
500ccd5
fix container queries for time entry rows
Onatcer Nov 20, 2025
6f37ad5
limit initially loaded time entries on time page
Onatcer Nov 20, 2025
b1bb724
use default api limit for fetching time entries
Onatcer Nov 20, 2025
280032e
allow employee manage task setting to organization
Onatcer Nov 25, 2025
2da3422
wip
StanBarrows Dec 1, 2025
bd2a681
wip
StanBarrows Dec 1, 2025
561ad40
Merge pull request #94 from codebar-ag/feature-update
StanBarrows Dec 1, 2025
7f634a0
wip
StanBarrows Dec 1, 2025
f6addf7
Bump dedoc/scramble in the minor-updates group across 1 directory
dependabot[bot] Dec 1, 2025
3cd5561
Merge pull request #96 from codebar-ag/feature-update
StanBarrows Dec 1, 2025
c3753f8
Merge pull request #97 from codebar-ag/dependabot/composer/main/minor…
StanBarrows Dec 1, 2025
b8be38b
wip
StanBarrows Dec 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,27 @@ protected function schedule(Schedule $schedule): void
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();

$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();

$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
// Convert string to a stable integer for seeding
/** @var int $seed Take the first 8 hex chars → 32-bit int */
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
$seed = abs($seed); // Ensure it's positive
mt_srand($seed);
$firstHour = mt_rand(0, 23);
$secondHour = ($firstHour + 12) % 24;
$minuteOffset = mt_rand(0, 59);
mt_srand(null); // Reset the random number generator

if (config('scheduling.tasks.self_hosting_check_for_update')) {
$schedule->command('self-host:check-for-update')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}

if (config('scheduling.tasks.self_hosting_telemetry')) {
$schedule->command('self-host:telemetry')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
}

$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
Expand Down
6 changes: 6 additions & 0 deletions app/Http/Controllers/Api/V1/ClientController.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@ protected function checkPermission(Organization $organization, string $permissio
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();

$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('name');

if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}

$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Controllers/Api/V1/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public function update(Organization $organization, OrganizationUpdateRequest $re
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();
}
Expand Down
54 changes: 51 additions & 3 deletions app/Http/Controllers/Api/V1/TaskController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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;
Expand All @@ -27,6 +28,26 @@ protected function checkPermission(Organization $organization, string $permissio
}
}

/**
* 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
*
Expand Down Expand Up @@ -75,7 +96,15 @@ public function index(Organization $organization, TaskIndexRequest $request): Ta
*/
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'tasks:create');
/** @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');
}

$task = new Task;
$task->name = $request->input('name');
$task->project_id = $request->input('project_id');
Expand All @@ -97,7 +126,17 @@ public function store(Organization $organization, TaskStoreRequest $request): Js
*/
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
{
$this->checkPermission($organization, 'tasks:update', $task);
// 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');
}

$task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
Expand All @@ -119,7 +158,16 @@ public function update(Organization $organization, Task $task, TaskUpdateRequest
*/
public function destroy(Organization $organization, Task $task): JsonResponse
{
$this->checkPermission($organization, 'tasks:delete', $task);
// 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');
}

if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public function rules(): array
'employees_can_see_billable_rates' => [
'boolean',
],
'employees_can_manage_tasks' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
Expand Down Expand Up @@ -102,6 +105,11 @@ public function getEmployeesCanSeeBillableRates(): ?bool
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;
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function rules(): array
'description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'tags' => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function rules(): array
'changes.description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'changes.tags' => [
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public function rules(): array
'description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'tags' => [
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Resources/V1/Organization/OrganizationResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public function toArray(Request $request): array
'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) */
Expand Down
13 changes: 13 additions & 0 deletions app/Models/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
Expand Down Expand Up @@ -62,6 +63,18 @@ public function projects(): HasMany
return $this->hasMany(Project::class, 'client_id');
}

/**
* @param Builder<Client> $builder
* @return Builder<Client>
*/
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
{
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
/** @var Builder<Project> $builder */
return $builder->visibleByEmployee($user);
});
}

/**
* @return Attribute<bool, never>
*/
Expand Down
2 changes: 2 additions & 0 deletions app/Models/Organization.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
* @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
Expand Down Expand Up @@ -70,6 +71,7 @@ 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,
Expand Down
15 changes: 14 additions & 1 deletion app/Providers/JetstreamServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,11 @@ protected function configurePermissions(): void
'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',
Expand All @@ -109,6 +112,7 @@ protected function configurePermissions(): void
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
Expand Down Expand Up @@ -157,8 +161,11 @@ protected function configurePermissions(): void
'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',
Expand All @@ -172,6 +179,7 @@ protected function configurePermissions(): void
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
Expand Down Expand Up @@ -217,8 +225,11 @@ protected function configurePermissions(): void
'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',
Expand All @@ -232,6 +243,7 @@ protected function configurePermissions(): void
'tags:update',
'tags:delete',
'clients:view',
'clients:view:all',
'clients:create',
'clients:update',
'clients:delete',
Expand All @@ -256,12 +268,13 @@ protected function configurePermissions(): void
'projects:view',
'tags:view',
'tasks:view',
'clients:view',
'time-entries:view:own',
'time-entries:create:own',
'time-entries:update:own',
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');

Jetstream::role(Role::Placeholder->value, 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
Expand Down
2 changes: 1 addition & 1 deletion app/Service/Export/ExportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public function export(Organization $organization): string
$client->id,
$client->name,
$client->organization_id,
$client->archived_at ?? '',
$client->archived_at?->toIso8601ZuluString() ?? '',
$client->created_at?->toIso8601ZuluString() ?? '',
$client->updated_at?->toIso8601ZuluString() ?? '',
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public function importData(string $data, string $timezone): void
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Description']) > 500) {
if (strlen($record['Description']) > 5000) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $record['Description'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ public function importData(string $data, string $timezone): void
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 500) {
if (strlen($record['Notes']) > 5000) {
throw new ImportException('Time entry note is too long');
}
$timeEntry->description = $record['Notes'];
Expand Down
2 changes: 1 addition & 1 deletion app/Service/Import/Importers/SolidtimeImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ public function importData(string $data, string $timezone): void
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($timeEntryRow['description']) > 500) {
if (strlen($timeEntryRow['description']) > 5000) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $timeEntryRow['description'];
Expand Down
14 changes: 13 additions & 1 deletion app/Service/PermissionStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,19 @@ private function getPermissionsByUser(Organization $organization, User $user): a
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);

return $roleObj->permissions ?? [];
$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;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.12.2",
"dedoc/scramble": "^0.13.6",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8",
Expand Down
Loading
Loading