Merge branch 'main' of github.com:solidtime-io/solidtime into feature/dashboard_empty_states

# Conflicts:
#	resources/js/Components/Dashboard/DashboardCard.vue
#	resources/js/Components/Dashboard/RecentlyTrackedTasksCard.vue
This commit is contained in:
Gregor Vostrak
2024-04-20 00:34:12 +02:00
52 changed files with 888 additions and 127 deletions

View File

@@ -1,4 +1,5 @@
APP_NAME=solidtime
VITE_APP_NAME=solidtime
APP_ENV=production
APP_DEBUG=false
APP_FORCE_HTTPS=true

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class EntityStillInUseApiException extends ApiException
{
private string $modelToDelete;
private string $modelInUse;
public function __construct(string $modelToDelete, string $modelInUse)
{
parent::__construct('', 0, null);
$this->modelToDelete = $modelToDelete;
$this->modelInUse = $modelInUse;
}
public const string KEY = 'entity_still_in_use';
/**
* Get the translated message for the exception.
*/
#[\Override]
public function getTranslatedMessage(): string
{
return __('exceptions.api.'.$this->getKey(), [
'modelToDelete' => __('validation.entities.'.$this->modelToDelete),
'modelInUse' => __('validation.entities.'.$this->modelInUse),
]);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Client\ClientCollection;
@@ -83,7 +84,7 @@ class ClientController extends Controller
/**
* Delete client
*
* @throws AuthorizationException
* @throws AuthorizationException|EntityStillInUseApiException
*
* @operationId deleteClient
*/
@@ -91,6 +92,10 @@ class ClientController extends Controller
{
$this->checkPermission($organization, 'clients:delete', $client);
if ($client->projects()->exists()) {
throw new EntityStillInUseApiException('client', 'project');
}
$client->delete();
return response()->json(null, 204);

View File

@@ -9,12 +9,21 @@ use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class InvitationController extends Controller
{
protected function checkPermission(Organization $organization, string $permission, ?OrganizationInvitation $organizationInvitation = null): void
{
parent::checkPermission($organization, $permission);
if ($organizationInvitation !== null && $organizationInvitation->organization_id !== $organization->id) {
throw new AuthorizationException('Invitation does not belong to organization');
}
}
/**
* List all invitations of an organization
*
@@ -54,4 +63,20 @@ class InvitationController extends Controller
return response()->json(null, 204);
}
/**
* Remove a pending invitation
*
* @throws AuthorizationException
*
* @operationId removeInvitation
*/
public function destroy(Organization $organization, OrganizationInvitation $invitation): JsonResponse
{
$this->checkPermission($organization, 'invitations:remove', $invitation);
$invitation->delete();
return response()->json(null, 204);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -12,6 +13,8 @@ use App\Http\Resources\V1\Member\MemberPivotResource;
use App\Http\Resources\V1\Member\MemberResource;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -68,7 +71,7 @@ class MemberController extends Controller
/**
* Remove a member of the organization.
*
* @throws AuthorizationException
* @throws AuthorizationException|EntityStillInUseApiException
*
* @operationId removeMember
*/
@@ -76,6 +79,13 @@ class MemberController extends Controller
{
$this->checkPermission($organization, 'members:delete', $membership);
if (TimeEntry::query()->where('user_id', $membership->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $membership->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
$membership->delete();
return response()

View File

@@ -4,17 +4,20 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectStoreRequest;
use App\Http\Requests\V1\Project\ProjectUpdateRequest;
use App\Http\Resources\V1\Project\ProjectCollection;
use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class ProjectController extends Controller
{
@@ -113,7 +116,7 @@ class ProjectController extends Controller
/**
* Delete project
*
* @throws AuthorizationException
* @throws AuthorizationException|EntityStillInUseApiException
*
* @operationId deleteProject
*/
@@ -121,7 +124,20 @@ class ProjectController extends Controller
{
$this->checkPermission($organization, 'projects:delete', $project);
$project->delete();
if ($project->tasks()->exists()) {
throw new EntityStillInUseApiException('project', 'task');
}
if ($project->timeEntries()->exists()) {
throw new EntityStillInUseApiException('project', 'time_entry');
}
DB::transaction(function () use (&$project) {
$project->members()->each(function (ProjectMember $member) {
$member->delete();
});
$project->delete();
});
return response()
->json(null, 204);

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Resources\V1\Tag\TagCollection;
use App\Http\Resources\V1\Tag\TagResource;
use App\Models\Organization;
use App\Models\Tag;
use App\Models\TimeEntry;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -83,7 +85,7 @@ class TagController extends Controller
/**
* Delete tag
*
* @throws AuthorizationException
* @throws AuthorizationException|EntityStillInUseApiException
*
* @operationId deleteTag
*/
@@ -91,6 +93,10 @@ class TagController extends Controller
{
$this->checkPermission($organization, 'tags:delete', $tag);
if (TimeEntry::query()->hasTag($tag)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('tag', 'time_entry');
}
$tag->delete();
return response()->json(null, 204);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Task\TaskIndexRequest;
use App\Http\Requests\V1\Task\TaskStoreRequest;
use App\Http\Requests\V1\Task\TaskUpdateRequest;
@@ -104,7 +105,7 @@ class TaskController extends Controller
/**
* Delete task
*
* @throws AuthorizationException
* @throws AuthorizationException|EntityStillInUseApiException
*
* @operationId deleteTask
*/
@@ -112,6 +113,10 @@ class TaskController extends Controller
{
$this->checkPermission($organization, 'tasks:delete', $task);
if ($task->timeEntries()->exists()) {
throw new EntityStillInUseApiException('task', 'time_entry');
}
$task->delete();
return response()

View File

@@ -40,6 +40,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\HandleInertiaRequests::class,
\App\Http\Middleware\ShareInertiaData::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
],

View File

@@ -6,6 +6,7 @@ namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Nwidart\Modules\Facades\Module;
class HandleInertiaRequests extends Middleware
{
@@ -38,7 +39,7 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
return array_merge(parent::share($request), [
//
'has_billing_extension' => Module::has('Billing'),
]);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\Organization;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\MessageBag;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
use Symfony\Component\HttpFoundation\Response;
class ShareInertiaData
{
/**
* Handle the incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
Inertia::share(array_filter([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
return [
'canCreateTeams' => $user !== null &&
Jetstream::userHasTeamFeatures($user) &&
Gate::forUser($user)->check('create', Jetstream::newTeamModel()),
'canManageTwoFactorAuthentication' => Features::canManageTwoFactorAuthentication(),
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
'flash' => $request->session()->get('flash', []),
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
'hasApiFeatures' => Jetstream::hasApiFeatures(),
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
'managesProfilePhotos' => Jetstream::managesProfilePhotos(),
];
},
'auth' => [
'user' => function () use ($request): array {
/** @var User|null $user */
$user = $request->user();
if ($user === null) {
return [];
}
return array_merge([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'email_verified_at' => $user->email_verified_at,
'current_team_id' => $user->current_team_id,
'profile_photo_path' => $user->profile_photo_path,
'timezone' => $user->timezone,
'week_start' => $user->week_start,
'profile_photo_url' => $user->profile_photo_url,
'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication())
&& ! is_null($user->two_factor_secret),
'current_team' => $user->currentTeam !== null ? [
'id' => $user->currentTeam->id,
'user_id' => $user->currentTeam->user_id,
'name' => $user->currentTeam->name,
'personal_team' => $user->currentTeam->personal_team,
'currency' => $user->currentTeam->currency,
] : null,
], array_filter([
'all_teams' => $user->organizations->map(function (Organization $organization): array {
return [
'id' => $organization->id,
'name' => $organization->name,
'personal_team' => $organization->personal_team,
'currency' => $organization->currency,
'membership' => [
'role' => $organization->membership->role,
],
];
})->all(),
]));
},
],
'errorBags' => function () {
/** @var array<string, MessageBag>|null $bags */
$bags = Session::get('errors')?->getBags();
$bagsCollection = collect($bags ?: []);
return $bagsCollection->mapWithKeys(function (MessageBag $bag, string $key) {
return [$key => $bag->messages()];
})->all();
},
]));
return $next($request);
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
@@ -24,8 +25,11 @@ use Laravel\Jetstream\Team as JetstreamTeam;
* @property int|null $billable_rate
* @property string $user_id
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Collection<User> $users
* @property Collection<string, User> $realUsers
* @property Membership $membership
*
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory()

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Models;
use Database\Factories\OrganizationInvitationFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
@@ -15,9 +17,12 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
* @property string $role
* @property string $organization_id
* @property-read Organization $organization
*
* @method static OrganizationInvitationFactory factory()
*/
class OrganizationInvitation extends JetstreamTeamInvitation
{
use HasFactory;
use HasUuids;
/**

View File

@@ -74,6 +74,14 @@ class Project extends Model
return $this->hasMany(Task::class);
}
/**
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class, 'project_id');
}
/**
* @param Builder<Project> $builder
*/

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Service\BillableRateService;
use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -31,6 +32,7 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
* @property string|null $task_id
* @property-read Task|null $task
*
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
*/
class TimeEntry extends Model
@@ -73,6 +75,14 @@ class TimeEntry extends Model
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
}
/**
* @param Builder<TimeEntry> $builder
*/
public function scopeHasTag(Builder $builder, Tag $tag): void
{
$builder->whereJsonContains('tags', $tag->getKey());
}
/**
* @return BelongsTo<User, TimeEntry>
*/

View File

@@ -10,6 +10,7 @@ use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams;
@@ -28,14 +31,19 @@ use Laravel\Passport\HasApiTokens;
* @property string $email
* @property string|null $email_verified_at
* @property string|null $password
* @property string|null $two_factor_secret
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property string|null $profile_photo_path
* @property-read Organization $currentTeam
* @property-read string $profile_photo_url
* @property Collection<Organization> $organizations
* @property Collection<TimeEntry> $timeEntries
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $current_team_id
* @property Collection<int, Organization> $organizations
* @property Collection<int, TimeEntry> $timeEntries
* @property Membership $membership
*
* @method HasMany<Organization> ownedTeams()
* @method static UserFactory factory()
@@ -99,6 +107,20 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
'week_start' => Weekday::Monday,
];
/**
* Get the URL to the user's profile photo.
*
* @return Attribute<string, never>
*/
protected function profilePhotoUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->profile_photo_path
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
: $this->defaultProfilePhotoUrl();
});
}
public function canAccessPanel(Panel $panel): bool
{
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
@@ -127,6 +149,14 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
return $this->hasMany(TimeEntry::class);
}
/**
* @return HasMany<ProjectMember>
*/
public function projectMembers(): HasMany
{
return $this->hasMany(ProjectMember::class, 'user_id');
}
/**
* @param Builder<User> $builder
*/

View File

@@ -86,5 +86,6 @@ class AppServiceProvider extends ServiceProvider
});
Route::model('member', Membership::class);
Route::model('invitation', OrganizationInvitation::class);
}
}

View File

@@ -93,6 +93,9 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view',
'organizations:update',
'import',
'invitations:view',
'invitations:create',
'invitations:remove',
'members:view',
'members:invite-placeholder',
'members:change-role',

View File

@@ -40,6 +40,35 @@ class DashboardService
return $result;
}
/**
* @return array{start: string, end: string, dates: array<string, array<string>>}
*/
private function lastDaysSplitInWindows(int $days, CarbonTimeZone $timeZone, int $windows): array
{
$result = [];
$windowSize = 24 / $windows;
$date = Carbon::now($timeZone)->startOfDay();
$end = $date->copy()->endOfDay()->utc()->toDateTimeString();
$start = $end;
for ($i = 0; $i < $days; $i++) {
$tempDate = $date->copy();
$start = $tempDate->utc()->toDateTimeString();
$tempWindows = [];
for ($j = 0; $j < $windows; $j++) {
$tempWindow = $tempDate->addHours($windowSize)->utc()->toDateTimeString();
$tempWindows[] = $tempWindow;
}
$result[$date->format('Y-m-d')] = $tempWindows;
$date->subDay();
}
return [
'start' => $start,
'end' => $end,
'dates' => $result,
];
}
/**
* @return Collection<int, string>
*/
@@ -385,114 +414,45 @@ class DashboardService
*/
public function lastSevenDays(User $user, Organization $organization): array
{
return [
[
'date' => '2024-02-26',
'duration' => 3600, // in seconds
// if that is too difficult we can just skip that for now
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-25',
'duration' => 7200, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-24',
'duration' => 10800, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-23',
'duration' => 14400, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-22',
'duration' => 18000, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-21',
'duration' => 21600, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
[
'date' => '2024-02-20',
'duration' => 25200, // in seconds
'history' => [
// duration in s of the 3h windows for the day starting at 00:00
300,
0,
500,
0,
100,
200,
100,
300,
],
],
$timezone = $this->timezoneService->getTimezoneFromUser($user);
$lastDaysSplitInWindows = $this->lastDaysSplitInWindows(7, $timezone, 8);
$data = collect(DB::select('
SELECT time_ranges.start, EXTRACT(epoch FROM sum(LEAST(time_ranges."end", coalesce(time_entries."end", :now::timestamp)) - GREATEST(time_ranges.start, time_entries.start))) AS aggregate
FROM (
SELECT time_range_starts.start AS start, time_range_starts.start + interval \'3 hours\' AS "end"
FROM generate_series(:start_time_ranges::timestamp, :end_time_ranges::timestamp, interval \'3 hours\') as time_range_starts (start)
) time_ranges
JOIN time_entries ON time_entries.start < time_ranges."end"
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
where time_entries.user_id = :user_id and
time_entries.organization_id = :organization_id
GROUP BY time_ranges.start
ORDER BY time_ranges.start
', [
'start_time_ranges' => $lastDaysSplitInWindows['start'],
'end_time_ranges' => $lastDaysSplitInWindows['end'],
'user_id' => $user->getKey(),
'organization_id' => $organization->getKey(),
'now' => Carbon::now()->toDateTimeString(),
]))->pluck('aggregate', 'start');
];
$response = [];
foreach ($lastDaysSplitInWindows['dates'] as $date => $windows) {
$history = [];
$duration = 0;
foreach ($windows as $window) {
$value = (int) ($data->get($window, null) ?? 0);
$history[] = $value;
$duration += $value;
}
$response[] = [
'date' => $date,
'duration' => $duration,
'history' => $history,
];
}
return $response;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OrganizationInvitation>
*/
class OrganizationInvitationFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'email' => $this->faker->unique()->safeEmail(),
'role' => Role::Employee->value,
'organization_id' => Organization::factory(),
];
}
public function forOrganization(Organization $organization): self
{
return $this->state(fn (array $attributes) => [
'organization_id' => $organization->getKey(),
]);
}
}

View File

@@ -22,7 +22,7 @@ class ProjectMemberFactory extends Factory
public function definition(): array
{
return [
'billable_rate' => $this->faker->numberBetween(50, 1000) * 100,
'billable_rate' => $this->faker->numberBetween(10, 10000) * 100,
'project_id' => Project::factory(),
'user_id' => User::factory(),
];

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -15,5 +16,6 @@ return [
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
],
];

View File

@@ -202,4 +202,16 @@ return [
'currency' => 'The :attribute field must be a valid currency code (ISO 4217).',
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'entities' => [
'organization' => 'organization',
'project' => 'project',
'task' => 'task',
'time_entry' => 'time entry',
'user' => 'user',
'client' => 'client',
'member' => 'member',
'project_member' => 'project member',
'tag' => 'tag',
],
];

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/favicons/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

BIN
public/favicons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3578 5835 c-1 -1 -61 -5 -133 -9 -135 -7 -196 -12 -255 -22 -19 -3
-51 -8 -70 -10 -19 -3 -51 -9 -70 -14 -19 -5 -53 -12 -74 -16 -47 -7 -203 -59
-296 -99 -444 -188 -719 -448 -855 -811 -25 -67 -63 -233 -70 -309 -6 -56 -6
-315 -1 -345 2 -14 7 -50 11 -80 22 -176 93 -359 192 -498 213 -299 588 -491
1338 -687 215 -56 395 -129 462 -188 41 -36 73 -95 75 -140 5 -87 -32 -146
-117 -186 -114 -53 -385 -60 -627 -15 -97 17 -262 72 -367 121 -117 54 -362
216 -389 257 -4 6 -21 24 -38 39 l-32 29 -225 -229 c-124 -125 -318 -322 -431
-438 l-205 -210 57 -55 c292 -278 615 -463 1078 -618 111 -38 254 -78 314 -88
14 -3 41 -9 60 -14 34 -8 137 -26 210 -35 180 -24 617 -24 805 0 712 92 1222
453 1390 981 66 209 78 509 30 785 -11 64 -51 172 -97 264 -137 275 -327 450
-685 629 -145 73 -485 191 -638 222 -93 18 -347 101 -433 140 -135 62 -187
122 -185 217 1 80 73 152 180 183 59 17 295 24 373 10 132 -22 331 -86 435
-138 73 -38 202 -133 286 -212 l73 -68 434 436 c389 391 433 438 421 453 -38
47 -57 69 -85 97 -252 253 -524 409 -929 534 -254 79 -441 113 -710 127 -60 4
-131 8 -157 10 -25 2 -48 2 -50 0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "solidtime",
"short_name": "solidtime",
"icons": [
{
"src": "/favicons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import Dropdown from '@/Components/Dropdown.vue';
import { colors } from '@/utils/color';
const model = defineModel<string>({ default: '' });
</script>
<template>
<div class="px-3">
<Dropdown align="bottom">
<template #trigger>
<div
:style="{
backgroundColor: model,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${model}30`,
}"
class="w-4 h-4 rounded-full cursor-pointer"></div>
</template>
<template #content>
<div class="text-white grid grid-cols-6 gap-3 px-3 py-3">
<div
v-for="color in colors"
:key="color"
@click="model = color"
:style="{
backgroundColor: color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) ${color}30`,
}"
class="w-4 h-4 rounded-full cursor-pointer"></div>
</div>
</template>
</Dropdown>
</div>
</template>
<style scoped></style>

View File

@@ -13,6 +13,7 @@ import { twMerge } from 'tailwind-merge';
import Badge from '@/Components/Common/Badge.vue';
import { useClientsStore } from '@/utils/useClients';
import { storeToRefs } from 'pinia';
import ProjectColorSelector from '@/Components/Common/Project/ProjectColorSelector.vue';
const { createProject } = useProjectsStore();
const { clients } = storeToRefs(useClientsStore());
@@ -28,6 +29,11 @@ const project = ref<CreateProjectBody>({
async function submit() {
await createProject(project.value);
show.value = false;
project.value = {
name: '',
color: getRandomColor(),
client_id: null,
};
}
const projectNameInput = ref<HTMLInputElement | null>(null);
@@ -54,14 +60,8 @@ const currentClientName = computed(() => {
<template #content>
<div class="flex items-center space-x-4">
<div class="px-3">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(5px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-4 h-4 rounded-full"></div>
</div>
<ProjectColorSelector
v-model="project.color"></ProjectColorSelector>
<div class="col-span-6 sm:col-span-4 flex-1">
<TextInput
id="projectName"

View File

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

View File

@@ -10,6 +10,17 @@
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
<link rel="manifest" href="/favicons/site.webmanifest">
<link rel="mask-icon" href="/favicons/safari-pinned-tab.svg" color="#000000">
<link rel="shortcut icon" href="/favicons/favicon.ico">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="/favicons/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
<!-- Scripts -->
@routes
@vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"])

View File

@@ -49,6 +49,7 @@ Route::middleware([
Route::name('invitations.')->group(static function () {
Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store');
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy');
});
// Project routes

View File

@@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
@@ -199,6 +200,27 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_destroy_endpoint_fails_if_client_is_still_in_use_by_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:delete',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.clients.destroy', [$data->organization->getKey(), $client->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The client is still used by a project and can not be deleted.');
$this->assertDatabaseHas(Client::class, [
'id' => $client->getKey(),
]);
}
public function test_destroy_endpoint_deletes_client(): void
{
// Arrange

View File

@@ -76,4 +76,52 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
$this->assertEquals('test@asdf.at', $invitation->email);
$this->assertEquals('employee', $invitation->role);
}
public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
// Act
$response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));
// Assert
$response->assertStatus(403);
}
public function test_delete_fails_if_invitation_belongs_to_different_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:remove',
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->create();
// Act
$response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));
// Assert
$response->assertStatus(403);
}
public function test_delete_removes_invitation(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:remove',
]);
Passport::actingAs($data->user);
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
// Act
$response = $this->deleteJson(route('api.v1.invitations.destroy', [$data->organization->getKey(), $invitation->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertNull(OrganizationInvitation::find($invitation->getKey()));
}
}

View File

@@ -6,6 +6,9 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Laravel\Passport\Passport;
@@ -154,6 +157,47 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(403);
}
public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_time_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
TimeEntry::factory()->forUser($data->user)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The member is still used by a time entry and can not be deleted.');
$this->assertDatabaseHas(Membership::class, [
'id' => $data->member->getKey(),
]);
}
public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_project_member(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
ProjectMember::factory()->forProject($project)->forUser($data->user)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The member is still used by a project member and can not be deleted.');
$this->assertDatabaseHas(Membership::class, [
'id' => $data->member->getKey(),
]);
}
public function test_destroy_member_succeeds_if_data_is_valid(): void
{
// Arrange

View File

@@ -7,6 +7,8 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use Laravel\Passport\Passport;
class ProjectEndpointTest extends ApiEndpointTestAbstract
@@ -333,6 +335,48 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:delete',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$task = Task::factory()->forProject($project)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The project is still used by a task and can not be deleted.');
$this->assertDatabaseHas(Project::class, [
'id' => $project->getKey(),
]);
}
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_time_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:delete',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$timeEntry = TimeEntry::factory()->forProject($project)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The project is still used by a time entry and can not be deleted.');
$this->assertDatabaseHas(Project::class, [
'id' => $project->getKey(),
]);
}
public function test_destroy_endpoint_deletes_project(): void
{
// Arrange

View File

@@ -6,6 +6,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Organization;
use App\Models\Tag;
use App\Models\TimeEntry;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
@@ -199,6 +200,29 @@ class TagEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_destroy_endpoint_fails_if_tag_is_still_in_use_by_a_time_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tags:delete',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forUser($data->user)->forOrganization($data->organization)->create([
'tags' => [$tag->getKey()],
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tags.destroy', [$data->organization->getKey(), $tag->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The tag is still used by a time entry and can not be deleted.');
$this->assertDatabaseHas(Tag::class, [
'id' => $tag->getKey(),
]);
}
public function test_destroy_endpoint_deletes_tag(): void
{
// Arrange

View File

@@ -7,6 +7,7 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Task;
use App\Models\TimeEntry;
use Laravel\Passport\Passport;
class TaskEndpointTest extends ApiEndpointTestAbstract
@@ -303,6 +304,27 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_destroy_endpoint_fails_if_task_is_still_in_use_by_a_time_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:delete',
]);
$task = Task::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forUser($data->user)->forTask($task)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.tasks.destroy', [$data->organization->getKey(), $task->getKey()]));
// Assert
$response->assertStatus(400);
$response->assertJsonPath('message', 'The task is still used by a time entry and can not be deleted.');
$this->assertDatabaseHas(Task::class, [
'id' => $task->getKey(),
]);
}
public function test_delete_endpoint_fails_if_user_has_no_permission_to_delete_tasks(): void
{
// Arrange

View File

@@ -6,6 +6,7 @@ namespace Tests\Unit\Model;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
@@ -117,4 +118,30 @@ class TimeEntryModelTest extends ModelTestAbstract
'start' => '2021-01-01 13:00:00',
]);
}
public function test_scope_has_tag_filter_by_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntry1 = TimeEntry::factory()->create([
'tags' => [$tag1->getKey()],
]);
$timeEntry2 = TimeEntry::factory()->create([
'tags' => [$tag2->getKey()],
]);
$timeEntry3 = TimeEntry::factory()->create([
'tags' => ['something-else'],
]);
$timeEntry4 = TimeEntry::factory()->create([
'tags' => null,
]);
// Act
$result = TimeEntry::hasTag($tag1)->get();
// Assert
$this->assertCount(1, $result);
$this->assertTrue($result->first()->is($timeEntry1));
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use App\Providers\Filament\AdminPanelProvider;
@@ -92,6 +93,27 @@ class UserModelTest extends ModelTestAbstract
$this->assertTrue($timeEntriesRel->first()->is($timeEntries->first()));
}
public function test_it_has_many_project_members(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$projectMembers = ProjectMember::factory()->forUser($user)->createMany(3);
$otherProjectMembers = ProjectMember::factory()->forUser($otherUser)->createMany(3);
// Act
$user->refresh();
$projectMembersRel = $user->projectMembers;
// Assert
$this->assertNotNull($projectMembersRel);
$this->assertCount(3, $projectMembersRel);
$this->assertEqualsCanonicalizing(
$projectMembers->pluck('id')->toArray(),
$projectMembersRel->pluck('id')->toArray()
);
}
public function test_scope_active_returns_only_non_placeholder_users(): void
{
// Arrange

View File

@@ -532,4 +532,132 @@ class DashboardServiceTest extends TestCase
],
], $result);
}
public function test_last_seven_days_returns_spend_time_in_the_last_seven_days_aggregated_in_three_hour_blocks(): void
{
// Arrange
$now = Carbon::create(2024, 4, 17, 12, 0, 0, 'Europe/Vienna');
$this->travelTo($now);
$organization = Organization::factory()->create();
$user = User::factory()->create([
'timezone' => 'Europe/Vienna',
]);
$timeEntryOverWholePeriod = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->subMinute()->utc(),
'end' => now('Europe/Vienna')->endOfDay()->addMinute()->utc(), // TODO: addMinute should not be necessary
]);
$timeEntryOverWholePeriodWithoutEnd = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->subMinute()->utc(),
'end' => null,
]);
$timeEntry1Task1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([
'start' => now('Europe/Vienna')->subMinutes(30)->utc(),
'end' => now('Europe/Vienna')->subMinutes(20)->utc(),
]);
// Act
$result = $this->dashboardService->lastSevenDays($user, $organization);
// Assert
$this->assertSame([
0 => [
'date' => '2024-04-17',
'duration' => 115800,
'history' => [
0 => 21600,
1 => 21600,
2 => 22200,
3 => 18000,
4 => 10800,
5 => 10800,
6 => 10800, // TODO
7 => 0, // TODO
],
],
1 => [
'date' => '2024-04-16',
'duration' => 172800,
'history' => [
0 => 21600,
1 => 21600,
2 => 21600,
3 => 21600,
4 => 21600,
5 => 21600,
6 => 21600,
7 => 21600,
],
],
2 => [
'date' => '2024-04-15',
'duration' => 172800,
'history' => [
0 => 21600,
1 => 21600,
2 => 21600,
3 => 21600,
4 => 21600,
5 => 21600,
6 => 21600,
7 => 21600,
],
],
3 => [
'date' => '2024-04-14',
'duration' => 172800,
'history' => [
0 => 21600,
1 => 21600,
2 => 21600,
3 => 21600,
4 => 21600,
5 => 21600,
6 => 21600,
7 => 21600,
],
],
4 => [
'date' => '2024-04-13',
'duration' => 172800,
'history' => [
0 => 21600,
1 => 21600,
2 => 21600,
3 => 21600,
4 => 21600,
5 => 21600,
6 => 21600,
7 => 21600,
],
],
5 => [
'date' => '2024-04-12',
'duration' => 172800,
'history' => [
0 => 21600,
1 => 21600,
2 => 21600,
3 => 21600,
4 => 21600,
5 => 21600,
6 => 21600,
7 => 21600,
],
],
6 => [
'date' => '2024-04-11',
'duration' => 172800,
'history' => [
0 => 21600,
1 => 21600,
2 => 21600,
3 => 21600,
4 => 21600,
5 => 21600,
6 => 21600,
7 => 21600,
],
],
], $result);
}
}