Compare commits

...

18 Commits

Author SHA1 Message Date
Gregor Vostrak
b09891aa1d fix vertical alignment of dropdown triggers (time entry row more) 2025-03-30 16:16:59 +02:00
Gregor Vostrak
62ee7d60e3 add light mode 2025-03-28 14:17:46 +01:00
Gregor Vostrak
7339b79e35 invalidate time entries on time tracker stop, fix task text overflow dashboard 2025-03-20 16:47:21 +01:00
Gregor Vostrak
6deb281565 add task information to recently time entries dashboard card 2025-03-20 15:18:12 +01:00
Gregor Vostrak
6ba0b19d40 change dashboard ui to use api instead of inertia props 2025-03-19 15:42:25 +01:00
Constantin Graf
01f6f0f5ea Add chart endpoints 2025-03-19 15:42:25 +01:00
Constantin Graf
aa3c64e496 Allow members:make-placeholder for admins 2025-03-10 16:26:08 +01:00
Gregor Vostrak
eee13897c9 add frontend to deactivate user 2025-03-10 15:43:08 +01:00
Gregor Vostrak
ac6e2b8079 fetch tasks on project show page, fixes #253 2025-03-10 15:43:08 +01:00
Gregor Vostrak
50cc7053e4 hide total billable amounts from employees when employees_can_see_billable_rates is disabled 2025-03-10 15:43:08 +01:00
Constantin Graf
73ce5f793d Fixed problem with merge into when project members already exist in destination member 2025-03-10 15:42:43 +01:00
Constantin Graf
02a716897d Fixed bug in merge into 2025-03-06 15:38:35 -05:00
Gregor Vostrak
e5ec11af44 add member merge frontend modal 2025-03-06 14:44:11 -05:00
Constantin Graf
ab263e725f Fixed bugs in member endpoints; Added merge-into member endpoint 2025-03-06 14:44:11 -05:00
Constantin Graf
f93c5370bf Add harvest and generic imports 2025-03-06 14:44:11 -05:00
dependabot[bot]
9faa8fe6e1 Bump codecov/codecov-action from 5.3.1 to 5.4.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.3.1 to 5.4.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.3.1...v5.4.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-05 16:03:57 -05:00
Gregor Vostrak
9948cb1fc1 add focus loop to tag dropdown to improve focus management 2025-03-05 12:03:37 +01:00
Gregor Vostrak
3026edd27b fix datepicker dropdown and taborder in create time entry 2025-03-05 11:22:57 +01:00
186 changed files with 4358 additions and 947 deletions

View File

@@ -63,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.3.1
uses: codecov/codecov-action@v5.4.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Enums\Role;
use App\Models\Member;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class CorrectionPlaceholderMembersCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:placeholder-members '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$members = Member::query()
->where('role', '!=', Role::Placeholder->value)
->whereHas('user', function (Builder $builder): void {
/** @var Builder<User> $builder */
$builder->where('is_placeholder', '=', true);
})
->get();
foreach ($members as $member) {
/** @var Member $member */
$member->role = Role::Placeholder->value;
if (! $dryRun) {
$member->save();
}
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Models\Organization;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ChartController extends Controller
{
/**
* @throws AuthorizationException
*
* @operationId weeklyProjectOverview
*
* @response array<int, array{value: int, name: string, color: string}>
*/
public function weeklyProjectOverview(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
return response()->json($weeklyProjectOverview);
}
/**
* @throws AuthorizationException
*
* @operationId latestTasks
*
* @response array<int, array{task_id: string, name: string, description: string|null, status: bool, time_entry_id: string|null}>
*/
public function latestTasks(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$latestTasks = $dashboardService->latestTasks($user, $organization);
return response()->json($latestTasks);
}
/**
* @throws AuthorizationException
*
* @operationId lastSevenDays
*
* @response array<int, array{ date: string, duration: int, history: array<int> }>
*/
public function lastSevenDays(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
return response()->json($lastSevenDays);
}
/**
* @throws AuthorizationException
*
* @operationId latestTeamActivity
*
* @response array<int, array{member_id: string, name: string, description: string|null, time_entry_id: string, task_id: string|null, status: bool }>
*/
public function latestTeamActivity(Organization $organization, DashboardService $dashboardService, PermissionStore $permissionStore): JsonResponse
{
$this->checkPermission($organization, 'charts:view:all');
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
return response()->json($latestTeamActivity);
}
/**
* @throws AuthorizationException
*
* @operationId dailyTrackedHours
*
* @response array<int, array{date: string, duration: int}>
*/
public function dailyTrackedHours(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
return response()->json($dailyTrackedHours);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyTime
*
* @response int
*/
public function totalWeeklyTime(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
return response()->json($totalWeeklyTime);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableTime
*
* @response int
*/
public function totalWeeklyBillableTime(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
return response()->json($totalWeeklyBillableTime);
}
/**
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableAmount
*
* @response array{value: int, currency: string}
*/
public function totalWeeklyBillableAmount(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
if (! $showBillableRate) {
throw new AuthorizationException('You do not have permission to view billable rates.');
}
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
return response()->json($totalWeeklyBillableAmount);
}
/**
* @throws AuthorizationException
*
* @operationId weeklyHistory
*
* @response array<int, array{date: string, duration: int}>
*/
public function weeklyHistory(Organization $organization, DashboardService $dashboardService): JsonResponse
{
$this->checkPermission($organization, 'charts:view:own');
$user = $this->user();
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
return response()->json($weeklyHistory);
}
}

View File

@@ -7,12 +7,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource;
@@ -24,6 +29,8 @@ use App\Service\MemberService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class MemberController extends Controller
{
@@ -63,6 +70,7 @@ class MemberController extends Controller
* @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId updateMember
*/
@@ -105,7 +113,9 @@ class MemberController extends Controller
/**
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
*
* @operationId makePlaceholder
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
@@ -114,6 +124,9 @@ class MemberController extends Controller
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
if ($member->role === Role::Placeholder->value) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
$memberService->makeMemberToPlaceholder($member);
@@ -122,10 +135,39 @@ class MemberController extends Controller
return response()->json(null, 204);
}
/**
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
*
* @operationId mergeMember
*/
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:merge-into', $member);
$user = $member->user;
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
}
$memberTo = Member::findOrFail($request->getMemberId());
DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {
$memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);
$member->delete();
$user->delete();
});
return response()->json(null, 204);
}
/**
* Invite a placeholder member to become a real member of the organization
*
* @throws AuthorizationException|UserNotPlaceholderApiException
* @throws AuthorizationException
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
*
* @operationId invitePlaceholder
*/
@@ -138,6 +180,10 @@ class MemberController extends Controller
throw new UserNotPlaceholderApiException;
}
if (Str::endsWith($user->email, '@solidtime-import.test')) {
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
}
$invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204);

View File

@@ -73,6 +73,7 @@ class ReportController extends Controller
false,
$report->properties->start,
$report->properties->end,
true
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
@@ -83,6 +84,7 @@ class ReportController extends Controller
true,
$report->properties->start,
$report->properties->end,
true
);
return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
@@ -180,6 +181,7 @@ class TimeEntryController extends Controller
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
@@ -211,7 +213,8 @@ class TimeEntryController extends Controller
$user->week_start,
false,
null,
null
null,
$showBillableRate
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -285,18 +288,18 @@ class TimeEntryController extends Controller
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
* }
*
@@ -312,6 +315,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
@@ -325,7 +329,8 @@ class TimeEntryController extends Controller
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
$request->getEnd(),
$showBillableRate
);
return [
@@ -359,6 +364,7 @@ class TimeEntryController extends Controller
}
$debug = $request->getDebug();
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
@@ -372,7 +378,8 @@ class TimeEntryController extends Controller
$user->week_start,
false,
$request->getStart(),
$request->getEnd()
$request->getEnd(),
$showBillableRate
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -382,7 +389,8 @@ class TimeEntryController extends Controller
$user->week_start,
true,
$request->getStart(),
$request->getEnd()
$request->getEnd(),
$showBillableRate
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
@@ -19,30 +20,14 @@ class DashboardController extends Controller
{
$user = $this->user();
$organization = $this->currentOrganization();
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
$weeklyHistory = $dashboardService->getWeeklyHistory($user, $organization);
$totalWeeklyTime = $dashboardService->totalWeeklyTime($user, $organization);
$totalWeeklyBillableTime = $dashboardService->totalWeeklyBillableTime($user, $organization);
$totalWeeklyBillableAmount = $dashboardService->totalWeeklyBillableAmount($user, $organization);
$weeklyProjectOverview = $dashboardService->weeklyProjectOverview($user, $organization);
$latestTasks = $dashboardService->latestTasks($user, $organization);
$lastSevenDays = $dashboardService->lastSevenDays($user, $organization);
$latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}
return Inertia::render('Dashboard', [
'weeklyProjectOverview' => $weeklyProjectOverview,
'latestTasks' => $latestTasks,
'lastSevenDays' => $lastSevenDays,
'latestTeamActivity' => $latestTeamActivity,
'dailyTrackedHours' => $dailyTrackedHours,
'totalWeeklyTime' => $totalWeeklyTime,
'totalWeeklyBillableTime' => $totalWeeklyBillableTime,
'totalWeeklyBillableAmount' => $totalWeeklyBillableAmount,
'weeklyHistory' => $weeklyHistory,
]);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return Inertia::render('Dashboard');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
// ID of the member to which the data should be transferred (destination)
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
];
}
public function getMemberId(): string
{
return (string) $this->input('member_id');
}
}

View File

@@ -18,20 +18,20 @@ use Illuminate\Http\Request;
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
class DetailedWithDataReportResource extends BaseResource

View File

@@ -6,7 +6,7 @@ namespace App\Listeners;
use App\Models\Member;
use App\Models\User;
use App\Service\UserService;
use App\Service\MemberService;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -17,8 +17,11 @@ class RemovePlaceholder
*/
public function handle(TeamMemberAdded $event): void
{
/** @var UserService $userService */
$userService = app(UserService::class);
$memberService = app(MemberService::class);
$member = Member::query()
->whereBelongsTo($event->team, 'organization')
->whereBelongsTo($event->user, 'user')
->firstOrFail();
$placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */
@@ -32,7 +35,7 @@ class RemovePlaceholder
foreach ($placeholders as $placeholder) {
/** @var Member $placeholder */
$placeholderUser = $placeholder->user;
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
$placeholder->delete();
$placeholderUser->delete();
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -59,6 +62,14 @@ class Member extends JetstreamMembership implements AuditableContract
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class, 'member_id');
}
/**
* @return HasMany<ProjectMember>
*/

View File

@@ -80,6 +80,8 @@ class JetstreamServiceProvider extends ServiceProvider
Jetstream::defaultApiTokenPermissions([]);
Jetstream::role(Role::Owner->value, 'Owner', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
@@ -123,6 +125,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
@@ -133,6 +136,8 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
@@ -172,8 +177,10 @@ class JetstreamServiceProvider extends ServiceProvider
'invitations:resend',
'invitations:remove',
'members:view',
'members:update',
'members:invite-placeholder',
'members:make-placeholder',
'members:merge-into',
'members:update',
'reports:view',
'reports:create',
'reports:update',
@@ -181,6 +188,8 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
'charts:view:own',
'charts:view:all',
'projects:view',
'projects:view:all',
'projects:create',
@@ -221,6 +230,7 @@ class JetstreamServiceProvider extends ServiceProvider
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [
'charts:view:own',
'projects:view',
'tags:view',
'tasks:view',

View File

@@ -33,6 +33,11 @@ class ColorService
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function isBuiltInColor(string $color): bool
{
return in_array($color, self::COLORS, true);
}
public function getRandomColor(?string $seed = null): string
{
if ($seed !== null) {

View File

@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -23,7 +24,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Service\ColorService;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class GenericProjectsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'name',
];
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if (isset($record['client']) && $record['client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['client'],
'organization_id' => $this->organization->id,
]);
}
if ($record['name'] !== '') {
$archivedAt = null;
if (isset($record['archived_at']) && $record['archived_at'] !== '') {
try {
$archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid');
}
}
$this->projectImportHelper->getKey([
'name' => $record['name'],
'organization_id' => $this->organization->id,
], [
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
'archived_at' => $archivedAt,
]);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[Override]
public function getName(): string
{
return __('importer.generic_projects.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.generic_projects.description');
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class GenericTimeEntriesImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'description',
'billable',
'client',
'project',
'tags',
'start',
'end',
'task',
'user_name',
'user_email',
];
/**
* @return array<string>
*
* @throws ImportException
*/
private function getTags(string $tags): array
{
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(',', $tags);
$tagIds = [];
foreach ($tagsParsed as $tagParsed) {
$tagId = $this->tagImportHelper->getKey([
'name' => Str::trim($tagParsed),
'organization_id' => $this->organization->id,
]);
$tagIds[] = $tagId;
}
return $tagIds;
}
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
'email' => $record['user_email'],
], [
'name' => $record['user_name'],
'timezone' => 'UTC',
'is_placeholder' => true,
]);
$memberId = $this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->description = $record['description'];
if (! in_array($record['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['billable'] === 'true';
$timeEntry->tags = $this->getTags($record['tags']);
$timeEntry->is_imported = true;
try {
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
}
if ($start === null) {
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
}
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.generic_time_entries.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.generic_time_entries.description');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class HarvestClientsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Client Name',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$this->clientImportHelper->getKey([
'name' => $record['Client Name'],
'organization_id' => $this->organization->id,
]);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.harvest_clients.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_clients.description');
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class HarvestProjectsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Client',
'Project',
'Budget',
'Billable Hours',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
if ($record['Project'] !== '') {
if (! isset($record['Budget']) || ! is_string($record['Budget'])) {
throw new ImportException('The value for "Budget" is invalid');
}
$estimatedTimeField = Str::replace(',', '.', $record['Budget']);
$estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null;
if ($estimatedTime === 0) {
$estimatedTime = null;
}
if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) {
throw new ImportException('The value for "Billable Hours" is invalid');
}
$billableHoursField = Str::replace(',', '.', $record['Billable Hours']);
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
$this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'color' => $this->colorService->getRandomColor(),
'client_id' => $clientId,
'estimated_time' => $estimatedTime,
'is_billable' => $billableHours > 0,
]);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.harvest_projects.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_projects.description');
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class HarvestTimeEntriesImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Date',
'Hours',
'Client',
'Project',
'Task',
'Billable?',
'First Name',
'Last Name',
'Notes',
];
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$firstname = $record['First Name'];
$lastname = $record['Last Name'];
$userId = $this->userImportHelper->getKey([
'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test',
], [
'name' => $firstname.' '.$lastname,
'timezone' => 'UTC',
'is_placeholder' => true,
]);
$memberId = $this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => true,
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long');
}
$timeEntry->description = $record['Notes'];
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable?'] === 'Yes';
$timeEntry->tags = [];
$timeEntry->is_imported = true;
// Start & End
try {
$date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone);
} catch (InvalidFormatException) {
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
}
if ($date === null) {
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
}
if (! isset($record['Hours']) || ! is_string($record['Hours'])) {
throw new ImportException('Hours ("'.($record['Hours'] ?? '<null>').'") is invalid');
}
$hoursField = Str::replace(',', '.', $record['Hours']);
if (! is_numeric($hoursField)) {
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
}
$hours = (float) $hoursField;
$timeEntry->start = $date->copy()->startOfDay()->utc();
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[Override]
public function getName(): string
{
return __('importer.harvest_time_entries.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.harvest_time_entries.description');
}
}

View File

@@ -15,6 +15,11 @@ class ImporterProvider
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::class,
'solidtime' => SolidtimeImporter::class,
'harvest_projects' => HarvestProjectsImporter::class,
'harvest_time_entries' => HarvestTimeEntriesImporter::class,
'harvest_clients' => HarvestClientsImporter::class,
'generic_projects' => GenericProjectsImporter::class,
'generic_time_entries' => GenericTimeEntriesImporter::class,
];
/**

View File

@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = json_decode($tags);

View File

@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -23,7 +24,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);

View File

@@ -7,15 +7,18 @@ namespace App\Service;
use App\Enums\Role;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember;
@@ -75,6 +78,7 @@ class MemberService
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws OnlyOwnerCanChangeOwnership
* @throws OrganizationNeedsAtLeastOneOwner
* @throws ChangingRoleOfPlaceholderIsNotAllowed
*/
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
{
@@ -82,6 +86,9 @@ class MemberService
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($oldRole === Role::Placeholder) {
throw new ChangingRoleOfPlaceholderIsNotAllowed;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
@@ -96,6 +103,39 @@ class MemberService
}
}
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
{
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($fromMember, 'member')
->update([
'user_id' => $toMember->user_id,
'member_id' => $toMember->getKey(),
]);
// Project members
ProjectMember::query()
->whereBelongsToOrganization($organization)
->whereBelongsTo($fromMember, 'member')
->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {
/** @var Builder<Project> $builder */
$builder->whereHas('members', function (Builder $builder) use ($toMember): void {
/** @var Builder<ProjectMember> $builder */
$builder->where('member_id', $toMember->getKey());
});
})
->update([
'user_id' => $toMember->user_id,
'member_id' => $toMember->getKey(),
]);
ProjectMember::query()
->whereBelongsToOrganization($organization)
->whereBelongsTo($fromMember, 'member')
->delete();
}
/**
* Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin.
@@ -132,7 +172,7 @@ class MemberService
$member->role = Role::Placeholder->value;
$member->save();
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
if ($makeSureUserHasAtLeastOneOrganization) {
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
}

View File

@@ -22,18 +22,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
private array $data;
@@ -52,18 +52,18 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup)

View File

@@ -27,21 +27,21 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
$group1Select = null;
@@ -96,7 +96,7 @@ class TimeEntryAggregationService
$group2Response[] = [
'key' => $group2 === '' ? null : (string) $group2,
'seconds' => (int) $aggregate->get(0)->aggregate,
'cost' => (int) $aggregate->get(0)->cost,
'cost' => $showBillableRate ? (int) $aggregate->get(0)->cost : null,
'grouped_type' => null,
'grouped_data' => null,
];
@@ -113,7 +113,7 @@ class TimeEntryAggregationService
$group1Response[] = [
'key' => $group1 === '' ? null : (string) $group1,
'seconds' => $group2ResponseSum,
'cost' => $group2ResponseCost,
'cost' => $showBillableRate ? $group2ResponseCost : null,
'grouped_type' => $group2Type?->value,
'grouped_data' => $group2Response,
];
@@ -133,7 +133,7 @@ class TimeEntryAggregationService
return [
'seconds' => $group1ResponseSum,
'cost' => $group1ResponseCost,
'cost' => $showBillableRate ? $group1ResponseCost : null,
'grouped_type' => $group1Type?->value,
'grouped_data' => $group1Response,
];
@@ -148,25 +148,25 @@ class TimeEntryAggregationService
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* cost: int|null
* }
*/
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
{
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end);
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
$keysGroup1 = [];
$keysGroup2 = [];
@@ -289,12 +289,12 @@ class TimeEntryAggregationService
* @param array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null|mixed,
* grouped_data: null|mixed
* }>
@@ -302,12 +302,12 @@ class TimeEntryAggregationService
* @return array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* seconds: int,
* cost: int,
* cost: int|null,
* grouped_type: null|mixed,
* grouped_data: null|mixed
* }>

View File

@@ -49,24 +49,10 @@ class UserService
}
/**
* Assign all organization entities (time entries, project members) from one user to another.
* This is useful when a placeholder user is replaced with a real user.
* This does NOT change the member id.
* This should only be used in if you want to change a member to a placeholder!
*/
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
{
/** @var Member|null $toMember */
$toMember = Member::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($toUser, 'user')
->first();
if ($toMember === null) {
throw new \InvalidArgumentException('User is not a member of the organization');
}
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
}
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
{
// Time entries
TimeEntry::query()
@@ -74,7 +60,6 @@ class UserService
->whereBelongsTo($fromUser, 'user')
->update([
'user_id' => $toUser->getKey(),
'member_id' => $toMember->getKey(),
]);
// Project members
@@ -83,7 +68,6 @@ class UserService
->whereBelongsTo($fromUser, 'user')
->update([
'user_id' => $toUser->getKey(),
'member_id' => $toMember->getKey(),
]);
}

View File

@@ -136,6 +136,7 @@ test('test that starting and updating the time while running works', async ({
await Promise.all([
page.waitForResponse(async (response) => {
return (
response.url().includes('/time-entries') &&
response.status() === 200 &&
(await response.headerValue('Content-Type')) ===
'application/json' &&

View File

@@ -18,6 +18,7 @@ export function newTimeEntryResponse(
) {
return page.waitForResponse(async (response) => {
return (
response.url().includes('/time-entries') &&
response.status() === status &&
(await response.headerValue('Content-Type')) ===
'application/json' &&

View File

@@ -4,15 +4,18 @@ declare(strict_types=1);
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -39,6 +42,9 @@ return [
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -13,6 +13,14 @@ return [
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],
'generic_projects' => [
'name' => 'Generic Projects',
'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],
'generic_time_entries' => [
'name' => 'Generic Time Entries',
'description' => 'If you want to import many time entries yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],
'clockify_projects' => [
'name' => 'Clockify Projects',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
@@ -38,4 +46,22 @@ return [
'name' => 'Solidtime',
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
],
'harvest_clients' => [
'name' => 'Harvest Clients',
'description' => '1. Go to "Manage" (top navigation)<br>2. Click on the "Clients"'.
'<br>3. Click on "Import/Export" and in the dropdown "Export clients to CSV" '.
'<br>',
],
'harvest_projects' => [
'name' => 'Harvest Projects',
'description' => '1. Go to "Projects" (top navigation)<br>2. Click on the "Export" button'.
'<br>3. Select which projects you would like to export and select CSV format '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],
'harvest_time_entries' => [
'name' => 'Harvest Time Entries',
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],
];

4
package-lock.json generated
View File

@@ -46,8 +46,8 @@
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vite-plugin-checker": "^0.8.0",
"vue": "^3.4.0",
"vue-tsc": "^2.0.28"
"vue": "^3.5.0",
"vue-tsc": "^2.2.0"
}
},
"node_modules/@alloc/quick-lru": {

View File

@@ -1,8 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
:root.dark {
--color-bg-primary: #0f1011;
--color-bg-secondary: #17181a;
--color-bg-tertiary: #2A2C32;
@@ -12,38 +11,114 @@
--color-text-secondary: #e3e4e6;
--color-text-tertiary: #969799;
--color-text-quaternary: #595a5c;
--color-border-primary: #191b1f;
--color-border-secondary: #23252a;
--color-border-tertiary: #2c2e33;
--color-border-quaternary: #393B42;
--color-input-border-active: rgba(255,255,255,0.3);
--color-accent-primary: 14, 165, 233; /* sky-500 */
--color-accent-secondary: 56, 189, 248;
--color-accent-tertiary: 125, 211, 252;
--color-accent-quaternary: 186, 230, 253;
--theme-color-chart: var(--color-accent-200);
--theme-color-default-background: var(--color-bg-primary);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary);
--theme-color-card-background-active: var(--color-bg-tertiary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 30%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
--theme-color-muted-text: var(--color-text-secondary);
--theme-color-row-background: var(--color-bg-primary);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-heading-border: var(--theme-color-card-border);
--theme-color-icon-default: var(--color-text-tertiary);
--theme-color-ring: rgba(255,255,255,0.5);
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
--theme-color-button-primary-text: var(--color-text-primary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-input-select-active: rgb(var(--color-accent-300));
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
}
:root.light {
--color-bg-primary: #F5F5F5;
--color-bg-secondary: #f7f7f8;
--color-bg-tertiary: #e1e1e3;
--color-bg-quaternary: #ffffff;
--color-bg-background: #ffffff;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #71717a;
--color-text-quaternary: #a1a1aa;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;
--color-border-quaternary: #d1d1d1;
--color-input-border-active: rgba(0,0,0,0.3);
--theme-color-menu-active: var(--color-bg-tertiary);
--theme-color-card-background: var(--color-bg-quaternary);
--theme-color-card-background-active: var(--color-bg-primary);
--theme-color-chart: var(--color-accent-400);
--theme-shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--theme-color-muted-text: var(--color-text-secondary);
--theme-color-row-background: var(--theme-color-card-background);
--theme-color-row-heading-background: var(--color-bg-secondary);
--theme-color-row-heading-border: var(--color-border-tertiary);
--theme-color-icon-default: var(--color-text-quaternary);
--theme-color-ring: rgba(0,0,0, 0.7);
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
--theme-color-button-primary-text: #FFFFFF;
--theme-color-input-background: var(--color-bg-quaternary);
--theme-color-input-select-active: rgb(var(--color-accent-400));
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
}
:root {
--theme-color-default-background: var(--color-bg-primary);
--theme-color-icon-active: rgb(var(--color-text-tertiary));
--theme-color-card-background-separator: var(--color-border-tertiary);
--theme-color-card-border: var(--color-border-secondary);
--theme-color-card-border-active: var(--color-border-tertiary);
--theme-color-default-background-separator: var(--color-border-primary);
--theme-color-primary-text: var(--color-text-primary);
--theme-color-muted-text: var(--color-text-secondary);
--theme-color-menu-active: var(--color-bg-secondary);
--theme-color-input-border: var(--color-border-quaternary);
--theme-color-input-background: var(--color-bg-secondary);
--theme-color-tab-background: var(--theme-color-card-background);
--theme-color-tab-background-active: var(--theme-color-card-background-active);
--theme-color-tab-border: var(--theme-color-card-border);
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
--theme-color-row-heading-background: var(--theme-color-card-background);
--theme-color-row-border: var(--theme-color-card-border);
--theme-color-row-heading-border: var(--theme-color-card-border);
--color-accent-50: 240, 249, 255; /* sky-50 */
--color-accent-100: 224, 242, 254; /* sky-100 */
--color-accent-200: 186, 230, 253; /* sky-200 */
--color-accent-300: 125, 211, 252; /* sky-300 */
--color-accent-400: 56, 189, 248; /* sky-400 */
--color-accent-500: 14, 165, 233; /* sky-500 */
--color-accent-600: 2, 132, 199; /* sky-600 */
--color-accent-700: 3, 105, 161; /* sky-700 */
--color-accent-800: 7, 89, 133; /* sky-800 */
--color-accent-900: 12, 74, 110; /* sky-900 */
--color-accent-950: 8, 47, 73; /* sky-950 */
--theme-button-secondary-background: var(--theme-color-card-background);
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
}
* {

View File

@@ -1,3 +1,16 @@
<script setup lang="ts">
import { onMounted, watch } from "vue";
import { theme } from "@/utils/theme.js";
onMounted(async () => {
document.documentElement.classList.add(theme.value);
watch(theme, (newTheme, oldTheme) => {
document.documentElement.classList.remove(oldTheme);
document.documentElement.classList.add(newTheme);
});
});
</script>
<template>
<div
class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-default-background">

View File

@@ -5,37 +5,37 @@ import { Link } from '@inertiajs/vue3';
<template>
<Link :href="'/'">
<svg
class="h-12 py-2"
class="h-12 py-2 text-text-primary"
viewBox="0 0 168 30"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M54.4081 6.78783C55.0812 7.46093 55.9225 7.79748 56.9322 7.79748C57.9936 7.79748 58.8479 7.46093 59.4951 6.78783C60.1682 6.08885 60.5048 5.22159 60.5048 4.18606C60.5048 3.17642 60.1682 2.3221 59.4951 1.62312C58.8479 0.924138 57.9936 0.574646 56.9322 0.574646C55.9225 0.574646 55.0812 0.924138 54.4081 1.62312C53.735 2.3221 53.3984 3.17642 53.3984 4.18606C53.3984 5.22159 53.735 6.08885 54.4081 6.78783Z"
fill="white" />
fill="currentColor" />
<path
d="M158.028 29.4272C155.905 29.4272 154.028 29.0129 152.397 28.1845C150.766 27.3302 149.485 26.1523 148.553 24.6508C147.621 23.1492 147.155 21.4277 147.155 19.4861C147.155 17.5703 147.608 15.8746 148.514 14.399C149.42 12.8975 150.65 11.7196 152.203 10.8653C153.782 9.98505 155.556 9.54495 157.523 9.54495C159.439 9.54495 161.134 9.95916 162.61 10.7876C164.112 11.5901 165.277 12.7163 166.105 14.166C166.959 15.5899 167.386 17.2208 167.386 19.0589C167.386 19.4472 167.361 19.8485 167.309 20.2627C167.283 20.651 167.205 21.1041 167.076 21.6218L150.339 21.6995V17.3503L164.396 17.2338L161.367 19.1366C161.342 18.0751 161.186 17.2079 160.901 16.5348C160.617 15.8358 160.202 15.3051 159.659 14.9427C159.115 14.5802 158.429 14.399 157.601 14.399C156.746 14.399 156.009 14.6061 155.387 15.0203C154.766 15.4345 154.287 16.017 153.95 16.7678C153.614 17.5185 153.446 18.4246 153.446 19.4861C153.446 20.5734 153.627 21.5053 153.989 22.282C154.352 23.0327 154.869 23.6023 155.543 23.9906C156.216 24.3789 157.044 24.5731 158.028 24.5731C158.96 24.5731 159.775 24.4178 160.474 24.1071C161.199 23.7964 161.846 23.3175 162.416 22.6703L165.95 26.2041C165.018 27.2655 163.879 28.068 162.532 28.6117C161.212 29.1553 159.711 29.4272 158.028 29.4272Z"
fill="white" />
fill="currentColor" />
<path
d="M114.306 29V10.0109H121.063V29H114.306ZM126.228 29V18.0104C126.228 17.2079 125.982 16.5866 125.49 16.1465C124.998 15.6805 124.39 15.4475 123.665 15.4475C123.147 15.4475 122.694 15.551 122.306 15.7581C121.917 15.9652 121.607 16.263 121.374 16.6513C121.167 17.0137 121.063 17.4668 121.063 18.0104L118.422 16.9619C118.422 15.4345 118.759 14.1272 119.432 13.0399C120.105 11.9526 121.011 11.1112 122.15 10.5158C123.289 9.92034 124.584 9.62262 126.034 9.62262C127.328 9.62262 128.493 9.93328 129.528 10.5546C130.59 11.15 131.431 11.9914 132.053 13.0787C132.674 14.166 132.985 15.4475 132.985 16.9231V29H126.228ZM138.149 29V18.0104C138.149 17.2079 137.903 16.5866 137.411 16.1465C136.92 15.6805 136.311 15.4475 135.586 15.4475C135.094 15.4475 134.641 15.551 134.227 15.7581C133.839 15.9652 133.528 16.263 133.295 16.6513C133.088 17.0137 132.985 17.4668 132.985 18.0104L129.024 17.8163C129.075 16.1076 129.451 14.6449 130.15 13.4282C130.849 12.2114 131.807 11.2795 133.023 10.6323C134.266 9.95917 135.664 9.62262 137.217 9.62262C138.693 9.62262 140.013 9.93328 141.178 10.5546C142.343 11.1759 143.249 12.082 143.896 13.2729C144.57 14.4378 144.906 15.8358 144.906 17.4668V29H138.149Z"
fill="white" />
<path d="M103.573 29V10.011H110.369V29H103.573Z" fill="white" />
fill="currentColor" />
<path d="M103.573 29V10.011H110.369V29H103.573Z" fill="currentColor" />
<path
d="M104.428 6.78783C105.101 7.46093 105.942 7.79748 106.952 7.79748C108.013 7.79748 108.867 7.46093 109.515 6.78783C110.188 6.08885 110.524 5.22159 110.524 4.18606C110.524 3.17642 110.188 2.3221 109.515 1.62312C108.867 0.924138 108.013 0.574646 106.952 0.574646C105.942 0.574646 105.101 0.924138 104.428 1.62312C103.755 2.3221 103.418 3.17642 103.418 4.18606C103.418 5.22159 103.755 6.08885 104.428 6.78783Z"
fill="white" />
fill="currentColor" />
<path
d="M90.2867 29V2.16681H97.0435V29H90.2867ZM86.0928 15.6417V10.011H101.237V15.6417H86.0928Z"
fill="white" />
fill="currentColor" />
<path
d="M72.4414 29.3883C70.6033 29.3883 68.9853 28.9612 67.5873 28.1068C66.1893 27.2525 65.0891 26.0876 64.2866 24.6119C63.5099 23.1104 63.1216 21.4147 63.1216 19.5249C63.1216 17.6091 63.5099 15.9005 64.2866 14.399C65.0891 12.8975 66.1764 11.7325 67.5485 10.9041C68.9464 10.0498 70.5774 9.62262 72.4414 9.62262C73.6322 9.62262 74.7454 9.84267 75.781 10.2828C76.8165 10.697 77.6837 11.2924 78.3827 12.0691C79.0817 12.8457 79.4959 13.7259 79.6254 14.7097V23.9906C79.4959 24.9744 79.0817 25.8805 78.3827 26.7089C77.6837 27.5373 76.8165 28.1975 75.781 28.6893C74.7454 29.1553 73.6322 29.3883 72.4414 29.3883ZM73.6452 23.3693C74.3959 23.3693 75.0431 23.214 75.5868 22.9033C76.1304 22.5668 76.5576 22.1137 76.8683 21.5442C77.2048 20.9487 77.3731 20.2627 77.3731 19.4861C77.3731 18.7353 77.2177 18.0751 76.9071 17.5056C76.5964 16.9361 76.1563 16.483 75.5868 16.1465C75.0431 15.8099 74.4089 15.6416 73.684 15.6416C72.9591 15.6416 72.3119 15.8099 71.7424 16.1465C71.1987 16.483 70.7586 16.949 70.4221 17.5444C70.1114 18.114 69.9561 18.7612 69.9561 19.4861C69.9561 20.2368 70.1114 20.9099 70.4221 21.5053C70.7327 22.0749 71.1728 22.5279 71.7424 22.8645C72.3119 23.201 72.9462 23.3693 73.6452 23.3693ZM83.7416 29H77.1012V23.9129L78.0721 19.2531L76.9848 14.6708V0.691162H83.7416V29Z"
fill="white" />
<path d="M53.5537 29V10.011H60.3494V29H53.5537Z" fill="white" />
<path d="M42.8608 29V0.691162H49.6177V29H42.8608Z" fill="white" />
fill="currentColor" />
<path d="M53.5537 29V10.011H60.3494V29H53.5537Z" fill="currentColor" />
<path d="M42.8608 29V0.691162H49.6177V29H42.8608Z" fill="currentColor" />
<path
d="M29.6176 29.4272C27.5724 29.4272 25.7473 29 24.1423 28.1457C22.5631 27.2655 21.3075 26.0746 20.3755 24.5731C19.4435 23.0457 18.9775 21.3371 18.9775 19.4472C18.9775 17.5574 19.4306 15.8746 20.3367 14.399C21.2687 12.8975 22.5372 11.7196 24.1423 10.8653C25.7473 9.98505 27.5595 9.54495 29.5788 9.54495C31.5981 9.54495 33.3973 9.98505 34.9765 10.8653C36.5816 11.7196 37.8501 12.8975 38.7821 14.399C39.714 15.8746 40.18 17.5574 40.18 19.4472C40.18 21.3371 39.714 23.0457 38.7821 24.5731C37.876 26.0746 36.6204 27.2655 35.0153 28.1457C33.4361 29 31.6369 29.4272 29.6176 29.4272ZM29.5788 23.4081C30.3295 23.4081 30.9768 23.2528 31.5204 22.9421C32.09 22.6056 32.5301 22.1396 32.8407 21.5442C33.1514 20.9487 33.3067 20.2627 33.3067 19.4861C33.3067 18.7094 33.1384 18.0363 32.8019 17.4668C32.4912 16.8713 32.0641 16.4183 31.5204 16.1076C30.9768 15.7711 30.3295 15.6028 29.5788 15.6028C28.8539 15.6028 28.2067 15.7711 27.6372 16.1076C27.0676 16.4442 26.6275 16.9102 26.3169 17.5056C26.0062 18.0751 25.8509 18.7482 25.8509 19.5249C25.8509 20.2756 26.0062 20.9487 26.3169 21.5442C26.6275 22.1396 27.0676 22.6056 27.6372 22.9421C28.2067 23.2528 28.8539 23.4081 29.5788 23.4081Z"
fill="white" />
fill="currentColor" />
<path
d="M9.20323 29.5437C8.03825 29.5437 6.88622 29.3883 5.74714 29.0777C4.63394 28.767 3.58547 28.3528 2.60172 27.835C1.64385 27.2914 0.828369 26.6701 0.155273 25.9711L3.84435 22.2043C4.46567 22.8515 5.20349 23.3564 6.0578 23.7188C6.938 24.0812 7.86998 24.2624 8.85373 24.2624C9.42328 24.2624 9.85043 24.1848 10.1352 24.0295C10.4459 23.8741 10.6012 23.6541 10.6012 23.3693C10.6012 22.9551 10.3811 22.6444 9.94104 22.4373C9.52683 22.2043 8.97023 22.0102 8.27125 21.8548C7.59815 21.6736 6.88623 21.4665 6.13547 21.2335C5.38471 20.9746 4.65983 20.6381 3.96085 20.2239C3.26187 19.8097 2.69232 19.2272 2.25222 18.4764C1.83801 17.7257 1.63091 16.7678 1.63091 15.6028C1.63091 14.3861 1.95451 13.3247 2.60172 12.4186C3.27481 11.4866 4.20679 10.7617 5.39765 10.2439C6.58851 9.70029 7.98648 9.42847 9.59155 9.42847C11.2225 9.42847 12.7758 9.71324 14.2514 10.2828C15.7271 10.8264 16.9179 11.6549 17.824 12.7681L14.0961 16.5348C13.4748 15.8358 12.7888 15.3569 12.038 15.098C11.2872 14.8132 10.6012 14.6708 9.97987 14.6708C9.38444 14.6708 8.95729 14.7615 8.6984 14.9427C8.43952 15.098 8.31008 15.318 8.31008 15.6028C8.31008 15.9394 8.51719 16.2112 8.9314 16.4183C9.3715 16.6254 9.9281 16.8196 10.6012 17.0008C11.3002 17.1561 12.0121 17.3632 12.737 17.6221C13.4877 17.881 14.1997 18.2434 14.8728 18.7094C15.5717 19.1495 16.1283 19.7449 16.5426 20.4957C16.9827 21.2465 17.2027 22.2173 17.2027 23.4081C17.2027 25.298 16.4778 26.7995 15.0281 27.9127C13.5783 29 11.6367 29.5437 9.20323 29.5437Z"
fill="white" />
fill="currentColor" />
</svg>
</Link>
</template>

View File

@@ -47,7 +47,7 @@ watchEffect(async () => {
<svg
v-if="style == 'danger'"
class="h-5 w-5 text-white"
class="h-5 w-5 text-text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -60,7 +60,7 @@ watchEffect(async () => {
</svg>
</span>
<p class="ms-3 font-medium text-sm text-white truncate">
<p class="ms-3 font-medium text-sm text-text-primary truncate">
{{ message }}
</p>
</div>
@@ -72,7 +72,7 @@ watchEffect(async () => {
aria-label="Dismiss"
@click.prevent="show = false">
<svg
class="h-5 w-5 text-white"
class="h-5 w-5 text-text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"

View File

@@ -108,7 +108,7 @@ const showBlackFridayBanner = computed(() => {
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
@@ -124,7 +124,7 @@ const showBlackFridayBanner = computed(() => {
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<CheckBadgeIcon class="w-4 text-white/50"></CheckBadgeIcon>
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
Your trial expires in {{ daysLeftInTrial() }} days.
@@ -138,7 +138,7 @@ const showBlackFridayBanner = computed(() => {
<div class="flex items-center space-x-2">
<Link v-if="canManageBilling()" href="/billing">
<div
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
@@ -154,7 +154,7 @@ const showBlackFridayBanner = computed(() => {
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-white/50"></XCircleIcon>
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
Your organization is currently blocked.
@@ -170,7 +170,7 @@ const showBlackFridayBanner = computed(() => {
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<div
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>
@@ -186,7 +186,7 @@ const showBlackFridayBanner = computed(() => {
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
<MainContainer class="flex items-center justify-between">
<div class="flex items-center space-x-1.5">
<XCircleIcon class="w-4 text-white/50"></XCircleIcon>
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
<div class="flex-1 space-x-1">
<span class="font-medium">
You are currently using the Free Plan.
@@ -202,7 +202,7 @@ const showBlackFridayBanner = computed(() => {
v-if="isBillingActivated() && canManageBilling()"
href="/billing">
<div
class="text-white font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
<span>Upgrade now</span>
</div>
</Link>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"></script>
<template>
<div class="rounded-lg border border-card-border">
<div class="rounded-lg border overflow-hidden border-card-border bg-card-background shadow-card">
<slot></slot>
</div>
</template>

View File

@@ -25,7 +25,7 @@ const props = defineProps<{
v-if="canUpdateClients()"
:aria-label="'Edit Client ' + props.client.name"
data-testid="client_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('edit')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
@@ -34,7 +34,7 @@ const props = defineProps<{
<button
v-if="canUpdateClients()"
:aria-label="'Archive Client ' + props.client.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
@@ -43,7 +43,7 @@ const props = defineProps<{
v-if="canDeleteClients()"
:aria-label="'Delete Client ' + props.client.name"
data-testid="client_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>

View File

@@ -29,7 +29,7 @@ const createClient = ref(false);
class="col-span-2 py-24 text-center">
<UserCircleIcon
class="w-8 text-icon-default inline pb-2"></UserCircleIcon>
<h3 class="text-white font-semibold">No clients found</h3>
<h3 class="text-text-primary font-semibold">No clients found</h3>
<p v-if="canCreateClients()" class="pb-5">
Create your first client now!
</p>

View File

@@ -5,11 +5,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white"></div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -41,13 +41,13 @@ const showEditModal = ref(false);
v-model:show="showEditModal"
:client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
</div>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span class="text-muted"> {{ projectCount }} Projects </span>
</div>
<div

View File

@@ -11,14 +11,14 @@ const emit = defineEmits<{
<MoreOptionsDropdown label="Actions for the invitation">
<button
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('resend')">
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
<span>Resend Invitation</span>
</button>
<button
data-testid="invitation_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>

View File

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

View File

@@ -63,7 +63,7 @@ const currentValue = computed(() => {
<template #trigger>
<Badge
tag="button"
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary bg-input-background font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<div v-if="currentValue" class="flex-1 truncate">
{{ currentValue }}

View File

@@ -145,7 +145,7 @@ useFocus(clientNameInput, { initialValue: true });
<InputError :message="errors.role" class="mt-2" />
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
class="relative z-0 mt-1 border border-card-border rounded-lg bg-card-background cursor-pointer">
<button
v-for="(role, i) in filterRoles(availableRoles)"
:key="role.key"
@@ -167,7 +167,7 @@ useFocus(clientNameInput, { initialValue: true });
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-white"
class="text-sm text-text-primary"
:class="{
'font-semibold':
addTeamMemberForm.role ==

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import {ref} from 'vue';
import {api, type Member} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import {useMutation} from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
import {useMembersStore} from "@/utils/useMembers";
const {handleApiRequestNotifications} = useNotificationsStore();
const show = defineModel('show', {default: false});
const saving = ref(false);
const props = defineProps<{
member: Member;
}>();
const turnToPlaceholderMutation = useMutation({
mutationFn: async () => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.makePlaceholder(undefined, {
params: {
organization: organizationId,
member: props.member.id
},
});
},
});
async function submit() {
saving.value = true;
await handleApiRequestNotifications(
() =>
turnToPlaceholderMutation.mutateAsync(),
'Deactivating the member was successful!',
'There was an error deactivating the user.',
() => {
show.value = false;
useMembersStore().fetchMembers()
}
);
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Deactivate User </span>
</div>
</template>
<template #content>
<p>
Deactivating the user <strong>{{ member.name }} </strong> will remove the user's access to
the organization. You will not be billed for inactive users and all time entries will be preserved.
</p>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Deactivate
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import {api, type Member} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
import {Badge} from "@/packages/ui/src";
import { useMutation } from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
member: Member;
}>();
const newMember = ref<string>('');
const mergeMember = useMutation({
mutationFn: async (newMemberId: string) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.mergeMember({
member_id: newMemberId,
}, {
params: {
organization: organizationId,
member: props.member.id
},
});
},
});
async function submit() {
const newMemberId = newMember.value;
if(newMemberId !== ''){
saving.value = true;
await handleApiRequestNotifications(
() =>
mergeMember.mutateAsync(newMemberId),
'Members successfully merged!',
'There was an error merging the members.',
() => {
show.value = false;
}
);
}
else{
addNotification(
'error',
'Please select a member to merge into.',
);
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Merge Member </span>
</div>
</template>
<template #content>
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
<div class="flex-1">
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<div class="flex-1 font-medium truncate">
{{ member.name }}
</div>
</Badge>
</div>
<div>
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
</div>
<div class="flex-1">
<MemberCombobox
v-model="newMember"
></MemberCombobox>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Merge Member
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,16 +1,19 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, UserCircleIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src';
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
import {canDeleteMembers, canMakeMembersPlaceholders, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
edit: [];
merge: [];
makePlaceholder: [];
}>();
const props = defineProps<{
member: Member;
}>();
</script>
<template>
@@ -21,7 +24,7 @@ const props = defineProps<{
<button
v-if="canUpdateMembers()"
:aria-label="'Edit Member ' + props.member.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('edit')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
@@ -31,11 +34,28 @@ const props = defineProps<{
v-if="canDeleteMembers()"
:aria-label="'Delete Member ' + props.member.name"
data-testid="member_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
<button
v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name"
data-testid="member_merge"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('merge')">
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
<span>Merge</span>
</button>
<button
v-if="props.member.role !== 'placeholder' && canMakeMembersPlaceholders()"
:aria-label="'Make Member ' + props.member.name + ' a placeholder'"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('makePlaceholder')">
<UserCircleIcon class="w-5 text-icon-active"></UserCircleIcon>
<span>Deactivate</span>
</button>
</div>
</MoreOptionsDropdown>
</template>

View File

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

View File

@@ -10,16 +10,20 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { ref } from 'vue';
import {computed, ref} from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
import MemberMakePlaceholderModal from "@/Components/Common/Member/MemberMakePlaceholderModal.vue";
const props = defineProps<{
member: Member;
}>();
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
const showMakeMemberPlaceholderModal = ref(false);
function removeMember() {
useMembersStore().removeMember(props.member.id);
@@ -45,12 +49,17 @@ async function invitePlaceholder(id: string) {
);
}
}
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
})
</script>
<template>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member.name }}
</span>
@@ -87,7 +96,8 @@ async function invitePlaceholder(id: string) {
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers()
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
@@ -96,11 +106,16 @@ async function invitePlaceholder(id: string) {
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"></MemberMoreOptionsDropdown>
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="showMakeMemberPlaceholderModal = true"
></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal v-model:show="showMakeMemberPlaceholderModal" :member="member"></MemberMakePlaceholderModal>
</TableRow>
</template>

View File

@@ -10,7 +10,7 @@
leave-to-class="opacity-0">
<div
v-if="show"
class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-card-border bg-card-background shadow-lg ring-1 ring-black text-white ring-opacity-5">
class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-card-border bg-card-background shadow-lg ring-1 ring-black text-text-primary ring-opacity-5">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
@@ -24,7 +24,7 @@
aria-hidden="true" />
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-white">
<p class="text-sm font-medium text-text-primary">
{{ title }}
</p>
<p v-if="message" class="mt-1 text-sm text-muted">
@@ -34,7 +34,7 @@
<div class="ml-4 flex flex-shrink-0">
<button
type="button"
class="inline-flex rounded-md bg-card-background text-muted hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
class="inline-flex rounded-md bg-card-background text-muted hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@click="show = false">
<span class="sr-only">Close</span>
<XMarkIcon class="h-5 w-5" aria-hidden="true" />

View File

@@ -9,7 +9,7 @@ defineProps<{
<template>
<h3
class="text-white font-bold text-sm sm:text-base flex items-center space-x-2 sm:space-x-2.5">
class="text-text-primary font-semibold text-sm sm:text-base flex items-center space-x-2 sm:space-x-2.5">
<component :is="icon" class="w-5 sm:w-6 text-icon-default"></component>
<span> {{ title }} </span>
</h3>

View File

@@ -1,32 +1,33 @@
<script setup lang="ts">
import ProjectBadge from '@/packages/ui/src/Project/ProjectBadge.vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useProjectsStore } from '@/utils/useProjects';
import Dropdown from '@/packages/ui/src/Input/Dropdown.vue';
import ProjectBadge from "@/packages/ui/src/Project/ProjectBadge.vue";
import { computed, nextTick, ref, watch } from "vue";
import { useProjectsStore } from "@/utils/useProjects";
import Dropdown from "@/packages/ui/src/Input/Dropdown.vue";
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxViewport,
} from 'radix-vue';
import { PlusCircleIcon } from '@heroicons/vue/20/solid';
import { storeToRefs } from 'pinia';
import { api } from '@/packages/api/src';
import { usePage } from '@inertiajs/vue3';
import { getRandomColor } from '@/packages/ui/src/utils/color';
import type { Project } from '@/packages/api/src';
import ProjectDropdownItem from '@/packages/ui/src/Project/ProjectDropdownItem.vue';
ComboboxViewport
} from "radix-vue";
import { PlusCircleIcon } from "@heroicons/vue/20/solid";
import { storeToRefs } from "pinia";
import { api } from "@/packages/api/src";
import { usePage } from "@inertiajs/vue3";
import { getRandomColor } from "@/packages/ui/src/utils/color";
import type { Project } from "@/packages/api/src";
import ProjectDropdownItem from "@/packages/ui/src/Project/ProjectDropdownItem.vue";
import { UseFocusTrap } from "@vueuse/integrations/useFocusTrap/component";
const searchValue = ref('');
const searchValue = ref("");
const searchInput = ref<HTMLElement | null>(null);
const model = defineModel<string | null>({
default: null,
default: null
});
const open = ref(false);
const projectsStore = useProjectsStore();
const emit = defineEmits(['update:modelValue', 'changed']);
const emit = defineEmits(["update:modelValue", "changed"]);
const { projects } = storeToRefs(projectsStore);
const projectDropdownTrigger = ref<HTMLElement | null>(null);
@@ -34,7 +35,7 @@ const shownProjects = computed(() => {
return projects.value.filter((project) => {
return project.name
.toLowerCase()
.includes(searchValue.value?.toLowerCase()?.trim() || '');
.includes(searchValue.value?.toLowerCase()?.trim() || "");
});
});
@@ -43,7 +44,7 @@ withDefaults(
border?: boolean;
}>(),
{
border: true,
border: true
}
);
@@ -61,13 +62,13 @@ async function addProjectIfNoneExists() {
{
name: searchValue.value,
color: getRandomColor(),
is_billable: false,
is_billable: false
},
{ params: { organization: page.props.auth.user.current_team_id } }
);
projects.value.unshift(response.data);
model.value = response.data.id;
searchValue.value = '';
searchValue.value = "";
open.value = false;
}
}
@@ -94,16 +95,16 @@ function isProjectSelected(project: Project) {
}
const selectedProjectName = computed(() => {
return currentProject.value?.name || 'No Project';
return currentProject.value?.name || "No Project";
});
const selectedProjectColor = computed(() => {
return currentProject.value?.color || 'var(--theme-color-icon-default)';
return currentProject.value?.color || "var(--theme-color-icon-default)";
});
function updateValue(project: Project) {
model.value = project.id;
emit('changed');
emit("changed");
}
</script>
@@ -113,76 +114,66 @@ function updateValue(project: Project) {
<ProjectBadge
ref="projectDropdownTrigger"
:color="selectedProjectColor"
size="large"
size="xlarge"
:border
tag="button"
:name="selectedProjectName"
class="focus:border-input-border-active focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"></ProjectBadge>
class="focus:border-input-border-active bg-input-background focus:outline-0 focus:bg-card-background-separator hover:bg-card-background-separator"></ProjectBadge>
</template>
<template #content>
<ComboboxRoot
:open="open"
:model-value="currentProject"
:search-term="searchValue"
class="relative"
@update:model-value="updateValue"
@update:search-term="(e) => console.log(e)">
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-white py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
ref="dropdownViewport"
class="w-60 max-h-60 overflow-y-scroll">
<ComboboxItem
v-if="searchValue === ''"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="null"
:value="{
id: null,
name: 'No Project',
color: 'var(--theme-color-icon-default)',
}">
<ProjectDropdownItem
name="No Project"
color="var(--theme-color-icon-default)"
selected></ProjectDropdownItem>
</ComboboxItem>
<ComboboxItem
v-for="project in shownProjects"
:key="project.id"
:value="project"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="project.id">
<ProjectDropdownItem
:selected="isProjectSelected(project)"
:color="project.color"
:name="project.name"></ProjectDropdownItem>
</ComboboxItem>
<div
v-if="
<UseFocusTrap
v-if="open"
:options="{ immediate: true, allowOutsideClick: true }">
<ComboboxRoot
v-model:search-term="searchValue"
:open="open"
:model-value="currentProject"
class="relative"
@update:model-value="updateValue"
>
<ComboboxAnchor>
<ComboboxInput
ref="searchInput"
class="bg-card-background border-0 placeholder-muted text-sm text-text-primary py-2.5 focus:ring-0 border-b border-card-background-separator focus:border-card-background-separator w-full"
placeholder="Search for a project..."
@keydown.enter="addProjectIfNoneExists" />
</ComboboxAnchor>
<ComboboxContent>
<ComboboxViewport
ref="dropdownViewport"
class="w-60 max-h-60 overflow-y-scroll">
<ComboboxItem
v-for="project in shownProjects"
:key="project.id"
:value="project"
class="data-[highlighted]:bg-card-background-active"
:data-project-id="project.id">
<ProjectDropdownItem
:selected="isProjectSelected(project)"
:color="project.color"
:name="project.name"></ProjectDropdownItem>
</ComboboxItem>
<div
v-if="
searchValue.length > 0 &&
shownProjects.length === 0
"
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
class="bg-card-background-active">
<div
class="flex space-x-3 items-center px-4 py-3 text-xs font-medium border-t rounded-b-lg border-card-background-separator">
<PlusCircleIcon
class="w-5 flex-shrink-0"></PlusCircleIcon>
<span
>Add "{{ searchValue }}" as a new
Project</span
>
>
</div>
</div>
</div>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</UseFocusTrap>
</template>
</Dropdown>
</template>

View File

@@ -24,7 +24,7 @@ const props = defineProps<{
v-if="canUpdateProjects()"
:aria-label="'Edit Project ' + props.project.name"
data-testid="project_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
@@ -33,7 +33,7 @@ const props = defineProps<{
<button
v-if="canUpdateProjects()"
:aria-label="'Archive Project ' + props.project.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('archive')">
<ArchiveBoxIcon class="w-5 text-icon-active"></ArchiveBoxIcon>
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
@@ -42,7 +42,7 @@ const props = defineProps<{
v-if="canDeleteProjects()"
:aria-label="'Delete Project ' + props.project.name"
data-testid="project_delete"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>

View File

@@ -65,7 +65,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">
<h3 class="text-text-primary font-semibold">
{{
canCreateProjects()
? 'No projects found'

View File

@@ -8,22 +8,22 @@ defineProps<{
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Client</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Total Time
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Progress
</div>
<div
v-if="showBillableRate"
class="px-3 py-1.5 text-left font-semibold text-white">
class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -69,7 +69,7 @@ const showEditProjectModal = ref(false);
:original-project="project"></ProjectEditModal>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
:style="{
backgroundColor: project.color,

View File

@@ -28,7 +28,7 @@ const currentMember = computed(() => {
:label="'Actions for Project Member ' + currentMember?.name">
<button
:aria-label="'Edit Project Member ' + currentMember?.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
<PencilSquareIcon class="w-5 text-icon-active"></PencilSquareIcon>
<span>Edit</span>
@@ -36,7 +36,7 @@ const currentMember = computed(() => {
<button
:aria-label="'Delete Project Member ' + currentMember?.name"
data-testid="project_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Remove from Team</span>

View File

@@ -33,7 +33,7 @@ const createProjectMember = ref(false);
class="col-span-5 py-24 text-center">
<UserGroupIcon
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-white font-semibold">No project members</h3>
<h3 class="text-text-primary font-semibold">No project members</h3>
<p class="pb-5">Add the first project member!</p>
<SecondaryButton
:icon="PlusIcon"

View File

@@ -5,13 +5,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Billable Rate
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -41,7 +41,7 @@ const showEditModal = ref(false);
:name="member?.name"
:project-member="projectMember"></ProjectMemberEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member?.name }}
</span>

View File

@@ -19,7 +19,7 @@ const props = defineProps<{
<button
v-if="canUpdateReport()"
:aria-label="'Edit Report ' + props.report.name"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('edit')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
@@ -28,7 +28,7 @@ const props = defineProps<{
<button
v-if="canDeleteReport()"
:aria-label="'Delete Report ' + props.report.name"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="border-b border-card-background-separator flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click.prevent="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>

View File

@@ -31,7 +31,7 @@ const gridTemplate = computed(() => {
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">
<h3 class="text-text-primary font-semibold">
No shared reports found
</h3>
<p v-if="canCreateProjects()" class="pb-5">

View File

@@ -5,16 +5,16 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Description
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Visibility
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Public URL
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">

View File

@@ -62,7 +62,7 @@ async function deleteReport() {
:original-report="report"></ReportEditModal>
<TableRow>
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span class="overflow-ellipsis overflow-hidden">
{{ report.name }}
</span>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, ref } from 'vue';
import { computed, provide } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import {
formatDate,
@@ -43,7 +43,8 @@ const xAxisLabels = computed(() => {
}
return props?.groupedData?.map((el) => formatDate(el.key ?? ''));
});
const accentColor = useCssVar('--color-accent-quaternary');
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const seriesData = computed(() => {
return props?.groupedData?.map((el) => {
@@ -90,7 +91,7 @@ const seriesData = computed(() => {
});
});
const option = ref({
const option = computed(() => ({
tooltip: {
trigger: 'item',
},
@@ -103,7 +104,7 @@ const option = ref({
backgroundColor: 'transparent',
xAxis: {
type: 'category',
data: xAxisLabels,
data: xAxisLabels.value,
markLine: {
lineStyle: {
color: 'rgba(125,156,188,0.1)',
@@ -118,7 +119,7 @@ const option = ref({
axisLabel: {
fontSize: 12,
fontWeight: 600,
color: 'rgba(255,255,255,0.7)',
color: labelColor.value,
margin: 16,
fontFamily: 'Outfit, sans-serif',
},
@@ -138,7 +139,7 @@ const option = ref({
},
series: [
{
data: seriesData,
data: seriesData.value,
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
@@ -147,7 +148,7 @@ const option = ref({
},
},
],
});
}));
</script>
<template>
@@ -158,7 +159,7 @@ const option = ref({
class="chart"
:option="option" />
<div v-else class="chart flex flex-col items-center justify-center">
<p class="text-lg text-white font-semibold">
<p class="text-lg text-text-primary font-semibold">
No time entries found
</p>
<p>Try to change the filters and time range</p>

View File

@@ -24,11 +24,11 @@ const activeClass = computed(() => {
tag="button"
:class="
twMerge(
'cursor-pointer hover:bg-card-background transition flex',
'cursor-pointer bg-input-background hover:bg-card-background transition flex',
activeClass
)
">
<component :is="icon" class="h-4 text-muted"></component>
<component :is="icon" class="-ml-0.5 h-4 w-4 text-text-quaternary"></component>
<span> {{ title }} </span>
<div
v-if="count"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, ref } from 'vue';
import { computed, provide } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
@@ -11,6 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core";
use([
CanvasRenderer,
@@ -32,6 +33,7 @@ type ReportingChartDataEntry = {
const props = defineProps<{
data: ReportingChartDataEntry | null;
}>();
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const seriesData = computed(() => {
return props.data?.map((el) => {
@@ -50,13 +52,16 @@ const seriesData = computed(() => {
};
});
});
const option = ref({
const option = computed(() => ({
tooltip: {
trigger: 'item',
},
legend: {
show: true,
top: '250px',
textStyle: {
color: labelColor.value,
},
},
backgroundColor: 'transparent',
series: [
@@ -69,13 +74,13 @@ const option = ref({
return formatHumanReadableDuration(value);
},
},
data: seriesData,
data: seriesData.value,
radius: ['30%', '60%'],
top: '-45%',
type: 'pie',
},
],
});
}));
</script>
<template>

View File

@@ -12,7 +12,7 @@ type AggregatedGroupedData = GroupedData & {
type GroupedData = {
seconds: number;
cost: number;
cost: number | null;
description: string | null | undefined;
};
@@ -26,7 +26,7 @@ const expanded = ref(false);
<template>
<div
class="contents text-white [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
<div
:class="
twMerge(
@@ -48,7 +48,7 @@ const expanded = ref(false);
{{ formatHumanReadableDuration(entry.seconds) }}
</div>
<div class="justify-end pr-6 flex items-center">
{{ formatCents(entry.cost, getOrganizationCurrencyString()) }}
{{entry.cost ? formatCents(entry.cost, getOrganizationCurrencyString()) : '--' }}
</div>
</div>
<div

View File

@@ -2,9 +2,13 @@
import { router } from '@inertiajs/vue3';
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
import {canViewReport} from "@/utils/permissions";
import {computed} from "vue";
defineProps<{
active: 'reporting' | 'detailed' | 'shared';
}>();
const showSharedReports = computed(() => canViewReport());
</script>
<template>
@@ -20,6 +24,7 @@ defineProps<{
>Detailed</TabBarItem
>
<TabBarItem
v-if="showSharedReports"
:active="active === 'shared'"
@click="router.visit(route('reporting.shared'))"
>Shared</TabBarItem

View File

@@ -7,9 +7,9 @@ defineProps<{
<template>
<div
class="rounded-lg bg-card-background border-card-border border px-3.5 py-2.5">
<dt class="font-bold text-sm text-muted">{{ title }}</dt>
<dd class="text-2xl text-white pt-1 font-bold">
class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-muted">{{ title }}</dt>
<dd class="text-2xl text-text-primary pt-1 font-semibold">
{{ value }}
</dd>
</div>

View File

@@ -8,7 +8,7 @@ const props = defineProps<{
const activeClass = computed(() => {
if (props.active) {
return 'bg-tab-background border border-tab-border text-white font-semibold';
return 'bg-tab-background border border-tab-border text-text-primary font-semibold';
}
return '';
});
@@ -19,7 +19,7 @@ const activeClass = computed(() => {
role="tab"
:class="
twMerge(
'rounded-md px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium hover:text-white focus-visible:outline-none',
'rounded-md px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium hover:text-text-primary focus-visible:outline-none',
activeClass
)
">

View File

@@ -16,7 +16,7 @@ const props = defineProps<{
<button
:aria-label="'Delete Tag ' + props.tag.name"
data-testid="tag_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>

View File

@@ -33,7 +33,7 @@ const showCreateTagModal = ref(false);
class="col-span-5 py-24 text-center">
<FolderPlusIcon
class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-white font-semibold">No tags found</h3>
<h3 class="text-text-primary font-semibold">No tags found</h3>
<p v-if="canCreateTags()" class="pb-5">
Create your first tag now!
</p>

View File

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

View File

@@ -17,7 +17,7 @@ function deleteTag() {
<template>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ tag.name }}
</span>

View File

@@ -2,7 +2,7 @@
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import { ref, watch } from "vue";
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { useFocus } from '@vueuse/core';
import { useTasksStore } from '@/utils/useTasks';
@@ -21,10 +21,16 @@ const props = defineProps<{
projectId: string;
}>();
const taskProjectId = ref<string>(props.projectId);
watch(() => props.projectId, (value) => {
taskProjectId.value = value;
});
async function submit() {
await createTask({
name: taskName.value,
project_id: props.projectId,
project_id: taskProjectId.value,
estimated_time: estimatedTime.value,
});
show.value = false;
@@ -53,13 +59,13 @@ useFocus(taskNameInput, { initialValue: true });
v-model="taskName"
type="text"
placeholder="Task Name"
class="mt-1 block w-full"
class="block w-full"
required
autocomplete="taskName"
@keydown.enter="submit()" />
</div>
<div class="col-span-6 sm:col-span-4">
<ProjectDropdown :model-value="projectId"></ProjectDropdown>
<ProjectDropdown v-model="taskProjectId"></ProjectDropdown>
</div>
</div>
<EstimatedTimeSection

View File

@@ -24,7 +24,7 @@ const props = defineProps<{
v-if="canUpdateTasks()"
:aria-label="'Edit Task ' + props.task.name"
data-testid="task_edit"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('edit')">
<PencilSquareIcon
class="w-5 text-icon-active"></PencilSquareIcon>
@@ -33,7 +33,7 @@ const props = defineProps<{
<button
v-if="canUpdateTasks()"
:aria-label="'Mark Task ' + props.task.name + ' as done'"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('done')">
<CheckCircleIcon class="w-5 text-icon-active"></CheckCircleIcon>
<span v-if="props.task.is_done">Mark as active</span>
@@ -43,7 +43,7 @@ const props = defineProps<{
v-if="canDeleteTasks()"
:aria-label="'Delete Task ' + props.task.name"
data-testid="task_delete"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('delete')">
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>

View File

@@ -41,7 +41,7 @@ const createTask = ref(false);
class="col-span-5 py-24 text-center">
<PlusCircleIcon
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-white font-semibold">No tasks found</h3>
<h3 class="text-text-primary font-semibold">No tasks found</h3>
<p v-if="canCreateTasks()" class="pb-5">
Create your first task now!
</p>

View File

@@ -5,16 +5,16 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Task Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Total Time
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">
Progress
</div>
<div class="px-3 py-1.5 text-left font-semibold text-white">Status</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -33,7 +33,7 @@ const showTaskEditModal = ref(false);
<template>
<TableRow>
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span class="overflow-ellipsis overflow-hidden">
{{ task.name }}
</span>

View File

@@ -48,7 +48,7 @@ const close = () => {
</div>
<div class="mt-3 text-center sm:mt-0 sm:ms-4 sm:text-start">
<h3 class="text-lg font-medium text-white">
<h3 class="text-lg font-medium text-text-primary">
<slot name="title" />
</h3>

View File

@@ -37,14 +37,14 @@ const isRunningInDifferentOrganization = computed(() => {
<div
class="w-full h-[calc(100%+10px)] absolute bg-default-background opacity-75 backdrop-blur-sm"></div>
<div class="flex space-x-3 items-center w-full z-20 justify-center">
<span class="text-xs text-center text-white">
<span class="text-xs text-center text-text-primary">
The Timer is running in a different organization.
</span>
</div>
</div>
<div>
<div class="text-muted font-extrabold text-xs">Current Timer</div>
<div class="text-white font-medium text-lg">
<div class="text-text-primary font-medium text-lg">
{{ currentTime }}
</div>
</div>

View File

@@ -1,29 +1,45 @@
<script lang="ts" setup>
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import { use } from 'echarts/core';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { BoltIcon } from '@heroicons/vue/20/solid';
import { HeatmapChart } from 'echarts/charts';
import VChart, { THEME_KEY } from "vue-echarts";
import { provide, computed } from "vue";
import { use } from "echarts/core";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import { BoltIcon } from "@heroicons/vue/20/solid";
import { HeatmapChart } from "echarts/charts";
import {
CalendarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import dayjs from 'dayjs';
VisualMapComponent
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import dayjs from "dayjs";
import {
firstDayIndex,
formatDate,
formatHumanReadableDuration,
getDayJsInstance,
} from '@/packages/ui/src/utils/time';
import { useCssVar } from '@vueuse/core';
getDayJsInstance
} from "@/packages/ui/src/utils/time";
import { useCssVar } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query";
import { getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
import { LoadingSpinner } from "@/packages/ui/src";
const props = defineProps<{
dailyHoursTracked: { duration: number; date: string }[];
}>();
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
const { data: dailyHoursTracked, isLoading } = useQuery({
queryKey: ["dailyTrackedHours", organizationId],
queryFn: () => {
return api.dailyTrackedHours({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value)
});
use([
TitleComponent,
@@ -31,89 +47,114 @@ use([
VisualMapComponent,
CalendarComponent,
HeatmapChart,
CanvasRenderer,
CanvasRenderer
]);
provide(THEME_KEY, 'dark');
provide(THEME_KEY, "dark");
const max = Math.max(
Math.max(...props.dailyHoursTracked.map((el) => el.duration)),
1
const max = computed(() => {
if (!isLoading.value && dailyHoursTracked.value) {
return Math.max(
Math.max(...dailyHoursTracked.value.map((el) => el.duration)),
1
);
} else {
return 1;
}
}
);
const backgroundColor = useCssVar('--color-bg-secondary');
const itemBackgroundColor = useCssVar('--color-bg-tertiary');
const option = ref({
tooltip: {},
visualMap: {
min: 0,
max: max,
type: 'piecewise',
orient: 'horizontal',
left: 'center',
top: 'center',
inRange: {
color: [itemBackgroundColor.value, '#2DBE45'],
},
show: false,
},
calendar: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
dayLabel: {
firstDay: firstDayIndex.value,
},
splitLine: {
show: false,
},
range: [
dayjs().format('YYYY-MM-DD'),
getDayJsInstance()()
.subtract(50, 'day')
.startOf('week')
.format('YYYY-MM-DD'),
],
itemStyle: {
color: 'transparent',
borderWidth: 8,
borderColor: backgroundColor.value,
},
yearLabel: { show: false },
},
series: {
type: 'heatmap',
coordinateSystem: 'calendar',
data: props.dailyHoursTracked.map((el) => [el.date, el.duration]),
itemStyle: {
borderRadius: 5,
borderColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
},
tooltip: {
valueFormatter: (value: number, dataIndex: number) => {
return (
formatDate(props.dailyHoursTracked[dataIndex].date) +
': ' +
formatHumanReadableDuration(value)
);
const backgroundColor = useCssVar('--color-card-background', null, { observe: true });
const itemBackgroundColor = useCssVar('--color-bg-tertiary', null, { observe: true });
const option = computed(() => {
return {
tooltip: {},
visualMap: {
min: 0,
max: max.value,
type: "piecewise",
orient: "horizontal",
left: "center",
top: "center",
inRange: {
color: [itemBackgroundColor.value, "#2DBE45"]
},
show: false
},
},
},
backgroundColor: 'transparent',
});
calendar: {
top: 40,
bottom: 20,
left: 40,
right: 10,
cellSize: [40, 40],
dayLabel: {
firstDay: firstDayIndex.value
},
splitLine: {
show: false
},
range: [
dayjs().format("YYYY-MM-DD"),
getDayJsInstance()()
.subtract(50, "day")
.startOf("week")
.format("YYYY-MM-DD")
],
itemStyle: {
color: "transparent",
borderWidth: 8,
borderColor: backgroundColor.value
},
yearLabel: { show: false }
},
series: {
type: "heatmap",
coordinateSystem: "calendar",
data: dailyHoursTracked?.value?.map((el) => [el.date, el.duration]) ?? [],
itemStyle: {
borderRadius: 5,
borderColor: "rgba(255,255,255,0.05)",
borderWidth: 1
},
tooltip: {
valueFormatter: (value: number, dataIndex: number) => {
if(dailyHoursTracked?.value){
return (
formatDate(dailyHoursTracked?.value[dataIndex].date) +
": " +
formatHumanReadableDuration(value)
);
}
else {
return "";
}
}
}
},
backgroundColor: "transparent"
};
});
</script>
<template>
<DashboardCard title="Activity Graph" :icon="BoltIcon">
<div class="px-2">
<v-chart
class="chart"
:autoresize="true"
:option="option"
style="height: 260px; background-color: transparent" />
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="dailyHoursTracked">
<v-chart
class="chart"
:autoresize="true"
:option="option"
style="height: 260px; background-color: transparent" />
</div>
<div v-else class="text-center text-gray-500 py-8">
No activity data available
</div>
</div>
</DashboardCard>
</template>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import VChart from 'vue-echarts';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useCssVar } from '@vueuse/core';
const props = defineProps<{
history: number[];
}>();
const accentColor = useCssVar('--color-accent-quaternary');
const accentColor = useCssVar('--theme-color-chart', null, { observe: true });
const seriesData = props.history.map((el) => {
const seriesData = computed(() => props.history.map((el) => {
return {
value: el,
...{
@@ -21,7 +21,7 @@ const seriesData = props.history.map((el) => {
},
},
};
});
}));
const option = ref({
grid: {
top: 0,

View File

@@ -16,7 +16,7 @@ import {
<div
class="px-3.5 py-2 flex justify-between @container border-b border-card-background-separator">
<div class="flex items-center min-w-[70px]">
<p class="font-semibold text-sm text-white">
<p class="font-semibold text-sm text-text-primary">
{{ formatHumanReadableDate(date) }}
</p>
</div>

View File

@@ -1,24 +1,53 @@
<script setup lang="ts">
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import DayOverviewCardEntry from '@/Components/Dashboard/DayOverviewCardEntry.vue';
import { CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
last7Days: {
date: string;
duration: number; // Total duration in seconds
history: number[]; // Array representing the duration in seconds of the 3h windows for the day
}[];
}>();
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import DayOverviewCardEntry from "@/Components/Dashboard/DayOverviewCardEntry.vue";
import { CalendarIcon } from "@heroicons/vue/20/solid";
import { getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
import { LoadingSpinner } from "@/packages/ui/src";
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Set up the query
const { data: last7Days, isLoading } = useQuery({
queryKey: ["lastSevenDays", organizationId],
queryFn: () => {
return api.lastSevenDays({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value),
placeholderData: Array.from({ length: 7 }, (_, i) => ({
date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
duration: 0,
history: Array(8).fill(0)
}))
});
</script>
<template>
<DashboardCard title="Last 7 Days" :icon="CalendarIcon">
<DayOverviewCardEntry
v-for="day in last7Days"
:key="day.date"
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
:date="day.date"
:history="day.history"
:duration="day.duration"></DayOverviewCardEntry>
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="last7Days">
<DayOverviewCardEntry
v-for="day in last7Days"
:key="day.date"
:class="last7Days.length === 7 ? 'last:border-0 first:pt-3' : ''"
:date="day.date"
:history="day.history"
:duration="day.duration"></DayOverviewCardEntry>
</div>
<div v-else class="text-center text-gray-500 py-8">
No data available
</div>
</DashboardCard>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import VChart, { THEME_KEY } from 'vue-echarts';
import { provide, ref } from 'vue';
import { provide } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { PieChart } from 'echarts/charts';
@@ -11,6 +11,7 @@ import {
TooltipComponent,
} from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useCssVar } from "@vueuse/core";
use([
CanvasRenderer,
@@ -22,6 +23,7 @@ use([
]);
provide(THEME_KEY, 'dark');
const labelColor = useCssVar('--color-text-secondary', null, { observe: true });
const props = defineProps<{
weeklyProjectOverview: {
@@ -46,13 +48,18 @@ const seriesData = props.weeklyProjectOverview.map((el) => {
},
};
});
const option = ref({
import { computed } from 'vue';
const option = computed(() => ({
tooltip: {
trigger: 'item',
},
legend: {
bottom: 'bottom',
top: '250px',
textStyle: {
color: labelColor.value,
},
},
backgroundColor: 'transparent',
series: [
@@ -71,7 +78,7 @@ const option = ref({
type: 'pie',
},
],
});
}));
</script>
<template>

View File

@@ -1,55 +1,106 @@
<script setup lang="ts">
import RecentlyTrackedTasksCardEntry from '@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import { CheckCircleIcon } from '@heroicons/vue/20/solid';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { PlusCircleIcon } from '@heroicons/vue/24/solid';
import { router } from '@inertiajs/vue3';
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import RecentlyTrackedTasksCardEntry from "@/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue";
import DashboardCard from "@/Components/Dashboard/DashboardCard.vue";
import { CheckCircleIcon } from "@heroicons/vue/20/solid";
import SecondaryButton from "@/packages/ui/src/Buttons/SecondaryButton.vue";
import { PlusCircleIcon } from "@heroicons/vue/24/solid";
import { router } from "@inertiajs/vue3";
import { getCurrentMembershipId, getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
import { LoadingSpinner } from "@/packages/ui/src";
const props = defineProps<{
latestTasks: {
id: string;
name: string;
project_name: string;
project_id: string;
}[];
}>();
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Function to fetch latest tasks using the API client
// Set up the query
const { data: timeEntriesResponse, isLoading, refetch } = useQuery({
queryKey: ["timeEntries", organizationId],
queryFn: () => {
return api.getTimeEntries({
params: {
organization: organizationId.value!
},
queries: {
member_id: getCurrentMembershipId()
}
});
},
enabled: computed(() => !!organizationId.value)
});
const latestTasks = computed(() => {
if (!timeEntriesResponse.value) {
return [];
}
return timeEntriesResponse.value.data;
});
const filteredLatestTasks = computed(() => {
// do not include running time entries
const finishedTimeEntries = latestTasks.value.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable
return finishedTimeEntries.filter((item, index, self) => {
return index === self.findIndex((t) => (
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
));
}).slice(0, 4);
});
// Listen for dashboard refresh events
window.addEventListener("dashboard:refresh", () => {
refetch();
});
</script>
<template>
<DashboardCard title="Recently Tracked Tasks" :icon="CheckCircleIcon">
<RecentlyTrackedTasksCardEntry
v-for="lastTask in props.latestTasks"
:key="lastTask.id"
:class="props.latestTasks.length === 4 ? 'last:border-0' : ''"
:project_id="lastTask.project_id"
:task_id="lastTask.id"
:title="lastTask.name"></RecentlyTrackedTasksCardEntry>
<DashboardCard title="Recent Time Entries" :icon="CheckCircleIcon">
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="filteredLatestTasks && filteredLatestTasks.length > 0">
<RecentlyTrackedTasksCardEntry
v-for="lastTask in filteredLatestTasks"
:key="lastTask.id"
:time-entry="lastTask"
:class="filteredLatestTasks.length === 4 ? 'last:border-0' : ''"></RecentlyTrackedTasksCardEntry>
</div>
<div
v-if="props.latestTasks.length === 0"
v-else
class="text-center flex flex-1 justify-center items-center">
<div>
<PlusCircleIcon
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-white font-semibold text-sm">
<h3 class="text-text-primary font-semibold text-sm">
No recent tasks found
</h3>
<p class="pb-5 text-sm">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects
>Go to Projects
</SecondaryButton>
</div>
</div>
<div
v-if="props.latestTasks.length === 1"
v-if="latestTasks && latestTasks.length === 1"
class="text-center flex flex-1 justify-center items-center text-sm">
<div>
<PlusCircleIcon
class="w-8 text-icon-default inline pb-2"></PlusCircleIcon>
<h3 class="text-white font-semibold">Add more tasks</h3>
<h3 class="text-text-primary font-semibold">Add more tasks</h3>
<p class="pb-5">Create tasks inside of a project!</p>
<SecondaryButton @click="router.visit(route('projects'))"
>Go to Projects
>Go to Projects
</SecondaryButton>
</div>
</div>

View File

@@ -6,17 +6,24 @@ import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import type { TimeEntry } from "@/packages/api/src";
import { useTasksStore } from "@/utils/useTasks";
import { ChevronRightIcon } from "@heroicons/vue/16/solid";
const props = defineProps<{
title: string;
project_id: string;
task_id: string;
timeEntry: TimeEntry
}>();
const { projects } = storeToRefs(useProjectsStore());
const project = computed(() => {
return projects.value.find((project) => project.id === props.project_id);
return projects.value.find((project) => project.id === props.timeEntry.project_id);
});
const {tasks} = storeToRefs(useTasksStore());
const task = computed(() => {
return tasks.value.find((task) => task.id === props.timeEntry.task_id);
});
const { currentTimeEntry } = storeToRefs(useCurrentTimeEntryStore());
@@ -26,24 +33,44 @@ async function startTaskTimer() {
if (currentTimeEntry.value.id) {
await setActiveState(false);
}
currentTimeEntry.value.project_id = props.project_id;
currentTimeEntry.value.task_id = props.task_id;
currentTimeEntry.value.description = props.timeEntry.description;
currentTimeEntry.value.project_id = props.timeEntry.project_id;
currentTimeEntry.value.task_id = props.timeEntry.task_id;
currentTimeEntry.value.tags = props.timeEntry.tags;
currentTimeEntry.value.billable = props.timeEntry.billable;
currentTimeEntry.value.start = getDayJsInstance().utc().format();
await setActiveState(true);
useCurrentTimeEntryStore().fetchCurrentTimeEntry();
}
</script>
<template>
<div
class="px-3.5 py-2 grid grid-cols-5 border-b border-b-card-background-separator">
<div class="col-span-4">
<p class="font-semibold text-white text-sm pb-1 overflow-ellipsis">
{{ title }}
<p class="font-medium text-text-primary text-sm pb-1 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else class="text-text-tertiary">No description</span>
</p>
<ProjectBadge
:name="project?.name"
:color="project?.color"></ProjectBadge>
size="base"
class="min-w-0 max-w-full"
:color="project?.color">
<div class="flex items-center lg:space-x-0.5 min-w-0">
<span class="whitespace-nowrap ">
{{ project?.name ?? 'No Project' }}
</span>
<ChevronRightIcon
v-if="task"
class="w-4 text-muted shrink-0"></ChevronRightIcon>
<div
v-if="task"
class="min-w-0 shrink truncate">
{{ task.name }}
</div>
</div>
</ProjectBadge>
</div>
<div class="flex items-center justify-center">
<TimeTrackerStartStop

View File

@@ -1,38 +1,57 @@
<script lang="ts" setup>
import { useQuery } from '@tanstack/vue-query';
import { computed } from 'vue';
import DashboardCard from '@/Components/Dashboard/DashboardCard.vue';
import TeamActivityCardEntry from '@/Components/Dashboard/TeamActivityCardEntry.vue';
import { UserGroupIcon } from '@heroicons/vue/20/solid';
import { router } from '@inertiajs/vue3';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { api } from '@/packages/api/src';
import { LoadingSpinner } from "@/packages/ui/src";
import { router } from '@inertiajs/vue3';
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Set up the query
const { data: latestTeamActivity, isLoading } = useQuery({
queryKey: ['latestTeamActivity', organizationId],
queryFn: () => {
return api.latestTeamActivity({
params: {
organization: organizationId.value!
}
})
},
enabled: computed(() => !!organizationId.value),
});
defineProps<{
latestTeamActivity: {
user_id: string;
name: string;
description: string;
time_entry_id: string;
task_id: string;
status: boolean;
}[];
}>();
</script>
<template>
<DashboardCard title="Team Activity" :icon="UserGroupIcon">
<TeamActivityCardEntry
v-for="activity in latestTeamActivity"
:key="activity.user_id"
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
:name="activity.name"
:description="activity.description"
:working="activity.status"></TeamActivityCardEntry>
<div v-if="isLoading" class="flex justify-center items-center h-40">
<LoadingSpinner />
</div>
<div v-else-if="latestTeamActivity">
<TeamActivityCardEntry
v-for="activity in latestTeamActivity"
:key="activity.time_entry_id"
:class="latestTeamActivity.length === 4 ? 'last:border-0' : ''"
:name="activity.name"
:description="activity.description"
:working="activity.status"></TeamActivityCardEntry>
</div>
<div v-else class="text-center text-gray-500 py-8">
No team activity found
</div>
<div
v-if="latestTeamActivity.length <= 1"
v-if="latestTeamActivity && latestTeamActivity.length <= 1"
class="text-center flex flex-1 justify-center items-center">
<div>
<UserGroupIcon
class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-white font-semibold text-sm">
<h3 class="text-text-primary font-semibold text-sm">
Invite your co-workers
</h3>
<p class="pb-5 text-sm">You can invite your entire team.</p>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
defineProps<{
name: string;
description: string;
description: string | null;
working?: boolean;
}>();
</script>
@@ -10,7 +10,7 @@ defineProps<{
<div class="px-4 py-2 2xl:py-3 border-b border-card-background-separator">
<div class="col-span-2">
<div class="flex justify-between">
<p class="font-semibold text-sm text-white">
<p class="font-semibold text-sm text-text-primary">
{{ name }}
</p>
<div

View File

@@ -1,25 +1,23 @@
<script setup lang="ts">
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from 'echarts/components';
import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, ref } from 'vue';
import StatCard from '@/Components/Common/StatCard.vue';
import { ClockIcon } from '@heroicons/vue/20/solid';
import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVar } from '@vueuse/core';
import { getOrganizationCurrencyString } from '@/utils/money';
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { BarChart } from "echarts/charts";
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from "echarts/components";
import VChart, { THEME_KEY } from "vue-echarts";
import { computed, provide } from "vue";
import StatCard from "@/Components/Common/StatCard.vue";
import { ClockIcon } from "@heroicons/vue/20/solid";
import CardTitle from "@/packages/ui/src/CardTitle.vue";
import LinearGradient from "zrender/lib/graphic/LinearGradient";
import ProjectsChartCard from "@/Components/Dashboard/ProjectsChartCard.vue";
import { formatHumanReadableDuration } from "@/packages/ui/src/utils/time";
import { formatCents } from "@/packages/ui/src/utils/money";
import { getWeekStart } from "@/packages/ui/src/utils/settings";
import { useCssVar } from "@vueuse/core";
import { getOrganizationCurrencyString } from "@/utils/money";
import { useQuery } from "@tanstack/vue-query";
import { getCurrentOrganizationId } from "@/utils/useUser";
import { api } from "@/packages/api/src";
use([
CanvasRenderer,
@@ -27,87 +25,22 @@ use([
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
LegendComponent
]);
provide(THEME_KEY, 'dark');
const props = defineProps<{
weeklyProjectOverview: {
value: number;
name: string;
color: string;
}[];
totalWeeklyTime: number;
totalWeeklyBillableTime: number;
totalWeeklyBillableAmount: {
value: number;
currency: string;
};
weeklyHistory: {
date: string;
duration: number;
}[];
}>();
const accentColor = useCssVar('--color-accent-quaternary');
const seriesData = computed(() => {
return props.weeklyHistory.map((el) => {
return {
value: el.duration,
...{
itemStyle: {
borderColor: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(' + accentColor.value + ',0.7)',
},
{
offset: 1,
color: 'rgba(' + accentColor.value + ',0.5)',
},
]),
emphasis: {
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(' + accentColor.value + ',0.9)',
},
{
offset: 1,
color: 'rgba(' + accentColor.value + ',0.7)',
},
]),
},
borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(' + accentColor.value + ',0.7)',
},
{
offset: 1,
color: 'rgba(' + accentColor.value + ',0.5)',
},
]),
},
},
};
});
});
provide(THEME_KEY, "dark");
const weekdays = computed(() => {
const daysOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const daysOrder = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const dayMapping: Record<string, string> = {
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
sunday: 'Sun',
monday: "Mon",
tuesday: "Tue",
wednesday: "Wed",
thursday: "Thu",
friday: "Fri",
saturday: "Sat",
sunday: "Sun"
};
if (dayMapping[getWeekStart()]) {
const customOrder = [];
const startIndex = daysOrder.indexOf(dayMapping[getWeekStart()]);
@@ -120,61 +53,185 @@ const weekdays = computed(() => {
} else {
return daysOrder;
}
});
const markLineColor = useCssVar('--color-border-secondary');
const option = ref({
tooltip: {
trigger: 'item',
const accentColor = useCssVar("--theme-color-chart", null, { observe: true });
// Get the organization ID using the utility function
const organizationId = computed(() => getCurrentOrganizationId());
// Set up the queries
const { data: weeklyProjectOverview } = useQuery({
queryKey: ["weeklyProjectOverview", organizationId],
queryFn: () => {
return api.weeklyProjectOverview({
params: {
organization: organizationId.value!
}
});
},
grid: {
top: 0,
right: 0,
bottom: 50,
left: 0,
},
backgroundColor: 'transparent',
xAxis: {
type: 'category',
data: weekdays.value,
axisLine: {
lineStyle: {
color: 'transparent', // Set desired color here
},
},
axisLabel: {
fontSize: 16,
fontWeight: 600,
margin: 24,
fontFamily: 'Outfit, sans-serif',
},
axisTick: {
lineStyle: {
color: 'transparent', // Set desired color here
},
},
},
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: markLineColor.value,
},
},
},
series: [
{
data: seriesData,
type: 'bar',
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
},
},
},
],
enabled: computed(() => !!organizationId.value)
});
const { data: totalWeeklyTime } = useQuery({
queryKey: ["totalWeeklyTime", organizationId],
queryFn: () => {
return api.totalWeeklyTime({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value)
});
const { data: totalWeeklyBillableTime } = useQuery({
queryKey: ["totalWeeklyBillableTime", organizationId],
queryFn: () => {
return api.totalWeeklyBillableTime({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value)
});
const { data: totalWeeklyBillableAmount } = useQuery({
queryKey: ["totalWeeklyBillableAmount", organizationId],
queryFn: () => {
return api.totalWeeklyBillableAmount({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value)
});
const { data: weeklyHistory } = useQuery({
queryKey: ["weeklyHistory", organizationId],
queryFn: () => {
return api.weeklyHistory({
params: {
organization: organizationId.value!
}
});
},
enabled: computed(() => !!organizationId.value)
});
const seriesData = computed(() => {
if (!weeklyHistory.value) {
return [];
}
return weeklyHistory.value?.map((el) => {
return {
value: el.duration,
...{
itemStyle: {
borderColor: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(" + accentColor.value + ",0.7)"
},
{
offset: 1,
color: "rgba(" + accentColor.value + ",0.5)"
}
]),
emphasis: {
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(" + accentColor.value + ",0.9)"
},
{
offset: 1,
color: "rgba(" + accentColor.value + ",0.7)"
}
])
},
borderRadius: [12, 12, 0, 0],
color: new LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(" + accentColor.value + ",0.7)"
},
{
offset: 1,
color: "rgba(" + accentColor.value + ",0.5)"
}
])
}
}
};
});
});
const markLineColor = useCssVar("--color-border-secondary", null, { observe: true });
const labelColor = useCssVar("--color-text-secondary", null, { observe: true });
const option = computed(() => {
return {
tooltip: {
trigger: "item"
},
grid: {
top: 0,
right: 0,
bottom: 50,
left: 0
},
backgroundColor: "transparent",
xAxis: {
type: "category",
data: weekdays.value,
axisLine: {
lineStyle: {
color: "transparent" // Set desired color here
}
},
axisLabel: {
fontSize: 16,
fontWeight: 600,
margin: 24,
fontFamily: "Outfit, sans-serif",
color: labelColor.value
},
axisTick: {
lineStyle: {
color: "transparent" // Set desired color here
}
}
},
yAxis: {
type: "value",
splitLine: {
lineStyle: {
color: markLineColor.value
}
}
},
series: [
{
data: seriesData.value,
type: "bar",
tooltip: {
valueFormatter: (value: number) => {
return formatHumanReadableDuration(value);
}
}
}
]
};
});
</script>
<template>
@@ -185,28 +242,35 @@ const option = ref({
title="This Week"
class="pb-8"
:icon="ClockIcon"></CardTitle>
<v-chart :autoresize="true" class="chart" :option="option" />
<v-chart
v-if="weeklyHistory"
:autoresize="true" class="chart" :option="option" />
</div>
<div class="space-y-6">
<StatCard
title="Spent Time"
:value="formatHumanReadableDuration(props.totalWeeklyTime)" />
:value="
totalWeeklyTime ?
formatHumanReadableDuration(totalWeeklyTime) : '--'" />
<StatCard
title="Billable Time"
:value="
formatHumanReadableDuration(props.totalWeeklyBillableTime)
totalWeeklyBillableTime ?
formatHumanReadableDuration(totalWeeklyBillableTime) : '--'
" />
<StatCard
title="Billable Amount"
:value="
totalWeeklyBillableAmount ?
formatCents(
props.totalWeeklyBillableAmount.value,
totalWeeklyBillableAmount.value,
getOrganizationCurrencyString()
)
) : '--'
" />
<ProjectsChartCard
v-if="weeklyProjectOverview"
:weekly-project-overview="
props.weeklyProjectOverview
weeklyProjectOverview
"></ProjectsChartCard>
</div>
</div>

View File

@@ -13,20 +13,20 @@ defineProps<{
v-if="as == 'button'"
type="submit"
v-bind="$attrs"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
class="block w-full px-4 py-2 text-start text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</button>
<a
v-else-if="as == 'a'"
:href="href"
class="block px-4 py-2 text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
class="block px-4 py-2 text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</a>
<Link
v-else
:href="href ?? ''"
class="block px-4 py-2 text-sm leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
class="block px-4 py-2 text-sm leading-5 text-text-primary hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
<slot />
</Link>
</div>

View File

@@ -14,7 +14,7 @@ const props = defineProps<{
icon?: Component;
current?: boolean;
href: string;
subItems?: { title: string; route: string }[];
subItems?: { title: string; route: string, show: boolean }[];
}>();
const open = useSessionStorage('nav-collapse-state-' + props.title, true);
@@ -32,7 +32,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5">
<div
class="text-muted group-hover:text-white group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between">
class="text-muted group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center justify-between">
<div class="flex items-center gap-x-2">
<component
:is="icon"
@@ -66,6 +66,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
:key="subItem.title"
class="w-full relative">
<NavigationSidebarLink
v-if="subItem.show"
:title="subItem.title"
:current="route().current(subItem.route)"
:href="

View File

@@ -14,8 +14,8 @@ defineProps<{
<div
:class="[
current
? 'bg-menu-active text-white'
: 'text-muted group-hover:text-white group-hover:bg-menu-active ',
? 'bg-menu-active text-text-primary'
: 'text-muted group-hover:text-text-primary group-hover:bg-menu-active ',
'group flex gap-x-2 rounded-md transition leading-6 py-1 px-2 font-medium text-sm items-center',
]">
<component

View File

@@ -1,7 +1,7 @@
<template>
<div class="md:col-span-1 flex justify-between">
<div class="px-4 sm:px-0">
<h3 class="text-lg font-medium text-white">
<h3 class="text-lg font-medium text-text-primary">
<slot name="title" />
</h3>

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