diff --git a/.env.production b/.env.production index fcab6160..dff8d774 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ APP_NAME=solidtime +VITE_APP_NAME=solidtime APP_ENV=production APP_DEBUG=false APP_FORCE_HTTPS=true diff --git a/app/Exceptions/Api/EntityStillInUseApiException.php b/app/Exceptions/Api/EntityStillInUseApiException.php new file mode 100644 index 00000000..e7c2683e --- /dev/null +++ b/app/Exceptions/Api/EntityStillInUseApiException.php @@ -0,0 +1,33 @@ +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), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/ClientController.php b/app/Http/Controllers/Api/V1/ClientController.php index 17e34892..5108511a 100644 --- a/app/Http/Controllers/Api/V1/ClientController.php +++ b/app/Http/Controllers/Api/V1/ClientController.php @@ -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); diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 20516877..397decf0 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -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); + } } diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index c6e7f506..38a5f12b 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -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() diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index b745bb85..4a4ff3a8 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -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); diff --git a/app/Http/Controllers/Api/V1/TagController.php b/app/Http/Controllers/Api/V1/TagController.php index 8a344917..240d50f7 100644 --- a/app/Http/Controllers/Api/V1/TagController.php +++ b/app/Http/Controllers/Api/V1/TagController.php @@ -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); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 96e90d89..20b96626 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -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() diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 818ffc02..783f5125 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, ], diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a0a4f8af..6c7087cf 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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'), ]); } } diff --git a/app/Http/Middleware/ShareInertiaData.php b/app/Http/Middleware/ShareInertiaData.php new file mode 100644 index 00000000..39907fdc --- /dev/null +++ b/app/Http/Middleware/ShareInertiaData.php @@ -0,0 +1,103 @@ + 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|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); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index d82712c5..2297a0f0 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -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 $users * @property Collection $realUsers + * @property Membership $membership * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 4f7570a4..f512bbdd 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -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; /** diff --git a/app/Models/Project.php b/app/Models/Project.php index 0cb4e57f..1713fa02 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -74,6 +74,14 @@ class Project extends Model return $this->hasMany(Task::class); } + /** + * @return HasMany + */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class, 'project_id'); + } + /** * @param Builder $builder */ diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 47af77c9..7dbfba22 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -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 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 $builder + */ + public function scopeHasTag(Builder $builder, Tag $tag): void + { + $builder->whereJsonContains('tags', $tag->getKey()); + } + /** * @return BelongsTo */ diff --git a/app/Models/User.php b/app/Models/User.php index bba306a0..fd0813bc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 $organizations - * @property Collection $timeEntries + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property string $current_team_id + * @property Collection $organizations + * @property Collection $timeEntries + * @property Membership $membership * * @method HasMany 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 + */ + 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 + */ + public function projectMembers(): HasMany + { + return $this->hasMany(ProjectMember::class, 'user_id'); + } + /** * @param Builder $builder */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d4ae8bae..33610d74 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -86,5 +86,6 @@ class AppServiceProvider extends ServiceProvider }); Route::model('member', Membership::class); + Route::model('invitation', OrganizationInvitation::class); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 17447a5f..bc787977 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -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', diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index f51bc2d1..869b53d0 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -40,6 +40,35 @@ class DashboardService return $result; } + /** + * @return array{start: string, end: string, dates: array>} + */ + 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 */ @@ -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; } } diff --git a/database/factories/OrganizationInvitationFactory.php b/database/factories/OrganizationInvitationFactory.php new file mode 100644 index 00000000..a4b2377f --- /dev/null +++ b/database/factories/OrganizationInvitationFactory.php @@ -0,0 +1,37 @@ + + */ +class OrganizationInvitationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]); + } +} diff --git a/database/factories/ProjectMemberFactory.php b/database/factories/ProjectMemberFactory.php index a586fee7..23a66fa4 100644 --- a/database/factories/ProjectMemberFactory.php +++ b/database/factories/ProjectMemberFactory.php @@ -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(), ]; diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index fb8c18e9..8a819eae 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -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.', ], ]; diff --git a/lang/en/validation.php b/lang/en/validation.php index 40766b93..0673a3a0 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -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', + ], ]; diff --git a/public/favicon.ico b/public/favicon.ico index e69de29b..de11e592 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicons/android-chrome-192x192.png b/public/favicons/android-chrome-192x192.png new file mode 100644 index 00000000..7c0d1022 Binary files /dev/null and b/public/favicons/android-chrome-192x192.png differ diff --git a/public/favicons/android-chrome-512x512.png b/public/favicons/android-chrome-512x512.png new file mode 100644 index 00000000..e20f002a Binary files /dev/null and b/public/favicons/android-chrome-512x512.png differ diff --git a/public/favicons/apple-touch-icon.png b/public/favicons/apple-touch-icon.png new file mode 100644 index 00000000..0052b5f2 Binary files /dev/null and b/public/favicons/apple-touch-icon.png differ diff --git a/public/favicons/browserconfig.xml b/public/favicons/browserconfig.xml new file mode 100644 index 00000000..d469e489 --- /dev/null +++ b/public/favicons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/public/favicons/favicon-16x16.png b/public/favicons/favicon-16x16.png new file mode 100644 index 00000000..eab1fd6a Binary files /dev/null and b/public/favicons/favicon-16x16.png differ diff --git a/public/favicons/favicon-32x32.png b/public/favicons/favicon-32x32.png new file mode 100644 index 00000000..c17fedab Binary files /dev/null and b/public/favicons/favicon-32x32.png differ diff --git a/public/favicons/favicon.ico b/public/favicons/favicon.ico new file mode 100644 index 00000000..de11e592 Binary files /dev/null and b/public/favicons/favicon.ico differ diff --git a/public/favicons/mstile-144x144.png b/public/favicons/mstile-144x144.png new file mode 100644 index 00000000..309e570c Binary files /dev/null and b/public/favicons/mstile-144x144.png differ diff --git a/public/favicons/mstile-150x150.png b/public/favicons/mstile-150x150.png new file mode 100644 index 00000000..cc4c9030 Binary files /dev/null and b/public/favicons/mstile-150x150.png differ diff --git a/public/favicons/mstile-310x150.png b/public/favicons/mstile-310x150.png new file mode 100644 index 00000000..86e723f3 Binary files /dev/null and b/public/favicons/mstile-310x150.png differ diff --git a/public/favicons/mstile-310x310.png b/public/favicons/mstile-310x310.png new file mode 100644 index 00000000..de3fc7e7 Binary files /dev/null and b/public/favicons/mstile-310x310.png differ diff --git a/public/favicons/mstile-70x70.png b/public/favicons/mstile-70x70.png new file mode 100644 index 00000000..215318d5 Binary files /dev/null and b/public/favicons/mstile-70x70.png differ diff --git a/public/favicons/safari-pinned-tab.svg b/public/favicons/safari-pinned-tab.svg new file mode 100644 index 00000000..90ccd8ce --- /dev/null +++ b/public/favicons/safari-pinned-tab.svg @@ -0,0 +1,25 @@ + + + + + + + diff --git a/public/favicons/site.webmanifest b/public/favicons/site.webmanifest new file mode 100644 index 00000000..3035a89b --- /dev/null +++ b/public/favicons/site.webmanifest @@ -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" +} diff --git a/resources/js/Components/Common/Project/ProjectColorSelector.vue b/resources/js/Components/Common/Project/ProjectColorSelector.vue new file mode 100644 index 00000000..afa1f2c4 --- /dev/null +++ b/resources/js/Components/Common/Project/ProjectColorSelector.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/resources/js/Components/Common/Project/ProjectCreateModal.vue b/resources/js/Components/Common/Project/ProjectCreateModal.vue index 8efc15e4..96fd2069 100644 --- a/resources/js/Components/Common/Project/ProjectCreateModal.vue +++ b/resources/js/Components/Common/Project/ProjectCreateModal.vue @@ -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({ async function submit() { await createProject(project.value); show.value = false; + project.value = { + name: '', + color: getRandomColor(), + client_id: null, + }; } const projectNameInput = ref(null); @@ -54,14 +60,8 @@ const currentClientName = computed(() => {